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
+
+
+
Area
Assessment
Rationale
+
+
Core correctness
Pass
All 217 repository tests pass on Python 3.11 and 3.14; additional regex and service battle suites passed.
+
Custom gates
Pass
Two novel gates and the bundled example worked through schema, validation, offline evaluation, downstream mutation, and live gRPC.
+
Security/content safety
Pass
Sentinel request/config values stay secret; malformed protobuf now returns cataloged INVALID_ARGUMENT without implementation details.
+
Resilience
Pass
Deadlines, cancellation, worker and RPC saturation, invalid policy replacement, oversize input, and close/restart behavior recovered.
+
Operator UX
Pass
Validation, preparation, and corpus failures now provide bounded actionable context; command discovery and narrow-terminal help are verified.
+
Packaging
Pass
sdist 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.
+
+
+
Finding
Resolution
Status
+
+
EG-QA-01
A 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-02
Gate preparation failures map to config_preparation_failed with safe built-in regex remediation rather than custom-resource guidance.
Verified
+
EG-QA-03
Policy errors now report one trusted schema path and safe category while excluding submitted values, Pydantic inputs, context, and URLs.
Verified
+
EG-QA-04
The 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-05
Automatic 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-06
Execution failures identify the validated case name and render safe results completed before the failure without exposing request content.
Verified
+
EG-QA-07
Bare invocation now renders help and exits 0.
Verified
+
EG-QA-08
Operations 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-09
Operations guidance distinguishes expected HTTP/2 GOAWAY/cancellation during zero-grace planned shutdown from actionable out-of-window transport faults.
Documented
+
EG-QA-10
Generated schemas rewrite Pydantic generic definition names and references to stable ConfiguredGate and PipelineConfig names.
Verified
+
EG-QA-11
egress-gate --version reports the installed distribution version and exits 0.
Verified
+
EG-QA-12
Plain help preserves complete option identifiers at 40 columns; concise command summaries remain complete at standard widths.
Verified
+
EG-QA-13
Operations 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'"
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.
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.
+
+
+
Gate
Purpose
Path exercised
Result
+
+
stamp-or-deny
Write a header unless a body token requires denial.
Normal 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
+
+
Normalized malformed protobuf errors. Decode failures now return stable, content-safe INVALID_ARGUMENT; a raw-wire regression protects the boundary.
+
Repaired the installed-package journey. Source-checkout commands are labeled, the installed quickstart is standalone, and documentation links are absolute.
+
Translated preparation errors precisely. Built-in GateConfigurationError failures map to a cataloged preparation response with relevant next steps.
+
Added safe structural diagnostics. Bounded locations such as pipeline.gates[2].config.scan and safe categories replace generic schema errors.
+
Honored color preferences.NO_COLOR applies to automatic service logging and has regression coverage.
+
Added case context to evaluator failures. The validated case name and already completed safe projections are retained.
+
Documented overload retry and readiness behavior. Operations guidance defines bounded RESOURCE_EXHAUSTED backoff and layered readiness checks.
+
Polished command discovery.--version, exit-0 bare help, complete narrow-width option identifiers, concise command summaries, and stable schema titles are verified.
+
+
Release stance after remediation: no open QA finding blocks controlled trusted-network deployment. Keep the new raw-wire, content-safety, CLI, schema, logging, and documentation checks in the release gate.
+
+
+
+
Scope and limitations
+
+
No upstream provider traffic or credential attachment was tested; those are outside EgressGate's documented pre-credentials boundary.
+
Live tests used a real grpc.aio server and generated stub, but did not launch the long-running CLI serve process on a production port.
+
TLS was not tested because this version intentionally documents plaintext gRPC on a restricted trusted network.
+
Cancellation cannot forcibly terminate already-running trusted synchronous Python code; QA verified bounded slot ownership and later recovery.
+
Offline evaluator mutation bodies are intentionally not directly assertable; downstream gates were used to prove mutation flow.
+
The existing latency CSV was not rerun as a performance benchmark; this session measured only targeted concurrency and recovery scenarios.
+
Dependency auditing covers published dependencies; the local unpublished EgressGate package cannot be resolved by pip-audit.
+
+
+
+
+
+
+
diff --git a/projects/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'
+
+ The external OpenShell supervisor talks only to the Egress Gate service adapter. The pipeline processor and gates use local domain models.
+
+
+## Component ownership
+
+| Module | Responsibility |
+| --- | --- |
+| `request.py` | Immutable request, headers, and `RequestMutations` |
+| `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 ordered gates and required default decision |
+| `request_processor.py` | Shared deadline, immutable snapshot construction, control flow, aggregation, and provenance |
+| `service/` | Protobuf validation/conversion, worker slots, lifecycle, and wire serialization |
+
+The CLI's offline evaluator parses bounded YAML. It uses
+`GateRegistry.prepare_processor()` and the production `RequestProcessor`. It
+does not add a second execution path or import the transport adapter.
+
+Only `service/` imports generated protobuf/gRPC bindings. The pipeline processor
+and gates receive domain values and can be tested offline.
+
+## Pipeline execution
+
+
+
+ Each gate proposes changes to its current snapshot. The pipeline processor builds the next snapshot, the service adapter maps the final mutations, and the OpenShell supervisor applies them.
+
+
+## Trust and state
+
+Registry factories and custom gate modules are trusted deployment code.
+Capabilities mechanically constrain outputs but do not sandbox Python reads.
+Prepared gates can use application-owned resources that are safe for concurrent
+use. Egress Gate does not close these resources.
+
+One validated policy and one prepared pipeline processor (`RequestProcessor`)
+are active at a time.
+Preparation is serialized and a complete candidate is published only after
+the shared deadline checks. A failed candidate leaves the existing policy
+unchanged. Gate instances are reused across worker threads, so per-request
+state must remain local to `evaluate`.
+
+See [Request lifecycle](request-lifecycle.md) and [Service boundary](service-boundary.md).
diff --git a/projects/egress-gate/docs/architecture/request-lifecycle.md b/projects/egress-gate/docs/architecture/request-lifecycle.md
new file mode 100644
index 00000000..47e42d56
--- /dev/null
+++ b/projects/egress-gate/docs/architecture/request-lifecycle.md
@@ -0,0 +1,64 @@
+---
+title: Request lifecycle
+description: How one OpenShell evaluation becomes an EgressResult.
+agent_markdown: true
+---
+
+# Request lifecycle
+
+
+
+ The pipeline processor updates local snapshots. The OpenShell supervisor applies final mutations to the intercepted request.
+
+
+## 1. Validate the transport
+
+The service checks the pre-credentials phase, exact protobuf configuration,
+context, target, header, and body bounds. Domain models then enforce bounded
+scalar and aggregate values. Invalid input produces a cataloged gRPC failure.
+
+## 2. Validate and prepare the policy
+
+The service converts the protobuf `Struct` to a mapping. The sealed
+`GateRegistry` validates it as an exact `EgressGateConfig`. The registry then
+prepares each configured gate and creates a `RequestProcessor`. Preparation
+uses one replacement lock and the request `Timeout`. The service publishes the
+candidate only after a final deadline check.
+
+## 3. Execute the pipeline
+
+For each configured gate, the Egress Gate pipeline processor:
+
+1. Check the shared deadline.
+2. Pass the current read-only `HttpRequest` snapshot to the gate.
+3. Reconstruct and validate the returned `GateEvaluation`.
+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 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 pipeline processor limits
+
+Deadline expiry, worker-slot exhaustion, mutation bounds, finding limits, and
+encoded output limits return an atomic deny with source `runtime_limit` and
+`egress_gate_limit_exceeded`. No partial mutations or findings are returned.
+Gate contract and execution failures remain gRPC failures.
+
+## 5. Serialize the result
+
+The Egress Gate service adapter maps the protobuf-free `EgressResult` to
+OpenShell's `HttpRequestResult`. It serializes the final body and header
+mutations, exactly five finding fields, and no internal provenance. An explicit
+empty replacement sets `has_body=true` with an empty body. After an allow, the
+OpenShell supervisor applies these mutations to the intercepted request.
diff --git a/projects/egress-gate/docs/architecture/service-boundary.md b/projects/egress-gate/docs/architecture/service-boundary.md
new file mode 100644
index 00000000..00f3ed4c
--- /dev/null
+++ b/projects/egress-gate/docs/architecture/service-boundary.md
@@ -0,0 +1,69 @@
+---
+title: Service boundary
+description: Protobuf conversion, validation, worker scheduling, and lifecycle.
+agent_markdown: true
+---
+
+# Service boundary
+
+The `service/` package is the only handwritten package that imports OpenShell
+protobuf/gRPC bindings. It owns exact encoded wire limits and transport status
+mapping. Domain models own protobuf-free invariants.
+
+The OpenShell supervisor owns the intercepted request. Egress Gate receives its
+request data over gRPC and works with local immutable `HttpRequest` snapshots.
+The Egress Gate service adapter returns a decision and final mutations; the
+supervisor applies allowed mutations to the intercepted request.
+
+## RPCs
+
+| RPC | Behavior |
+| --- | --- |
+| `Describe` | Advertise Egress Gate and the pre-credentials HTTP binding |
+| `ValidateConfig` | Validate a complete registry-backed pipeline without publishing it |
+| `EvaluateHttpRequest` | Adapt one request, prepare/reuse policy, execute, and serialize |
+
+The configuration arrives as `google.protobuf.Struct`. The adapter normalizes
+safe integral doubles before strict domain validation and rejects oversized
+encoded configuration before registry parsing.
+
+## Shared deadline and workers
+
+`EvaluateHttpRequest` creates one monotonic `Timeout`. That same deadline is
+used for semaphore acquisition, policy preparation, replacement-lock waits,
+gate execution, and final result checks. `RequestProcessor.process` accepts the
+caller-owned timeout and never creates or stores one.
+
+Synchronous work runs in a bounded four-slot executor. The gRPC server permits
+sixteen concurrent RPCs. Cancellation does not stop Python code that already
+runs in a worker. The worker owns its slot until it exits.
+
+## Wire findings and mutations
+
+The current OpenShell `Finding` contains exactly `type`, `label`, `count`,
+`confidence`, and `severity`. `SourcedFinding.source_gate`, decision sources,
+and traces belong to the pipeline processor and are not serialized. Decision
+sources use a strict `kind`-discriminated union. The adapter rechecks protobuf
+finding and header sizes before returning a response.
+
+`RequestMutations` is Egress Gate's internal aggregate. A gate returns it with
+`proceed` instead of modifying its input. The pipeline processor validates and
+applies it to a new local `HttpRequest` snapshot for the next gate.
+
+At the service boundary, the adapter maps the accumulated
+`RequestMutations.replacement_body` to `HttpRequestResult.body` and `has_body`.
+It maps each ordered header operation to
+`HttpRequestResult.header_mutations`. `None` means no body replacement, while
+empty bytes are emitted with `has_body=true`. The OpenShell supervisor applies
+these wire mutations after an allow.
+
+## Lifecycle and errors
+
+The active policy contains one validated configuration and one prepared
+pipeline processor. An equal configuration reuses the active pipeline
+processor. The service prepares a changed candidate before it publishes that
+candidate. An invalid candidate does not replace the active policy.
+
+Invalid input maps to `INVALID_ARGUMENT`. Internal gate or service failures map
+to `INTERNAL`. A pipeline processor limit denial is not a gRPC failure. It uses
+`egress_gate_limit_exceeded`.
diff --git a/projects/privacy-guard/docs/assets/analysis/privacy-guard-latency-vs-prompt-size.svg b/projects/egress-gate/docs/assets/analysis/egress-gate-latency-vs-prompt-size.svg
similarity index 97%
rename from projects/privacy-guard/docs/assets/analysis/privacy-guard-latency-vs-prompt-size.svg
rename to projects/egress-gate/docs/assets/analysis/egress-gate-latency-vs-prompt-size.svg
index e6b18663..fb18bc60 100644
--- a/projects/privacy-guard/docs/assets/analysis/privacy-guard-latency-vs-prompt-size.svg
+++ b/projects/egress-gate/docs/assets/analysis/egress-gate-latency-vs-prompt-size.svg
@@ -1,6 +1,6 @@
diff --git a/projects/privacy-guard/docs/configuration.md b/projects/privacy-guard/docs/configuration.md
deleted file mode 100644
index 357769ae..00000000
--- a/projects/privacy-guard/docs/configuration.md
+++ /dev/null
@@ -1,281 +0,0 @@
----
-title: Configure policies
-description: Configure Privacy Guard stages, actions, catalogs, and OpenShell middleware routing.
-agent_markdown: true
----
-
-# Configure policies
-
-Privacy Guard configuration is embedded in an OpenShell
-`network_middlewares` entry. The policy determines:
-
-- which provider endpoints use Privacy Guard
-- the order of entity-processing stages
-- each engine's exact configuration
-- whether detections are reported, blocked, or replaced
-- OpenShell's behavior when the middleware RPC fails
-
-Privacy Guard validates the complete configuration before processing a request.
-
-## Complete middleware entry
-
-```yaml
-network_middlewares:
- privacy_guard_replace:
- name: Replace email addresses and customer IDs
- middleware: privacy-guard
- order: 0
- config:
- entity_processing:
- stages:
- - name: identifiers
- config:
- engine: regex
- pattern_catalog:
- entities:
- - name: email
- rules:
- - name: conventional-email
- pattern: '(? stage 1 -> stage 2 -> final replacement text
-```
-
-Detection offsets belong to the input revision seen by the stage that produced
-them. Findings aggregate by stage, entity, and confidence.
-
-## Detection actions
-
-Set `on_detection.action` to one of:
-
-| Action | Engine strategy | No detections | Detections |
-| --- | --- | --- | --- |
-| `detect` | `DETECT` | Allow original body | Allow original body and report findings |
-| `block` | `DETECT` | Allow original body | Deny with `privacy_guard_blocked` |
-| `replace` | `REPLACE` | Allow final stage output | Allow final stage output and report findings |
-
-`replace` requires every configured stage to support replacement and to satisfy
-its engine-specific replacement requirements. A replacement recipe may remain
-configured when the action is `detect` or `block`; it is not used in those
-modes.
-
-## Common policy recipes
-
-### Detect without changing the request
-
-```yaml
-entity_processing:
- stages:
- - name: identifiers
- config:
- engine: regex
- pattern_catalog: patterns.yaml
-on_detection:
- action: detect
-```
-
-Use this to observe findings while leaving the provider-bound body unchanged.
-
-### Block requests containing configured entities
-
-```yaml
-entity_processing:
- stages:
- - name: restricted-values
- config:
- engine: regex
- pattern_catalog: patterns.yaml
-on_detection:
- action: block
-```
-
-The request is denied only when at least one configured entity is detected.
-
-### Replace entities
-
-```yaml
-entity_processing:
- stages:
- - name: identifiers
- config:
- engine: regex
- pattern_catalog: patterns.yaml
- replacement:
- strategy: template
- template: "[{entity}]"
-on_detection:
- action: replace
-```
-
-`{entity}` is replaced with the catalog entity name. For example,
-`user@example.com` becomes `[email]`.
-
-### Run multiple stages
-
-```yaml
-entity_processing:
- stages:
- - name: structured-identifiers
- config:
- engine: regex
- pattern_catalog: identifiers.yaml
- replacement:
- strategy: template
- template: "[{entity}]"
- - name: organization-model
- config:
- engine: acme-pii
- model_profile: organization-default
- replacement:
- strategy: native
-on_detection:
- action: replace
-```
-
-The `acme-pii` engine and its configuration are examples of a custom
-installation. The running registry must contain every engine named by the
-policy.
-
-## Regex catalogs
-
-`RegexEngine` accepts an inline catalog or a relative YAML path.
-
-Inline:
-
-```yaml
-pattern_catalog:
- entities:
- - name: customer-id
- rules:
- - name: prefixed-eight-digit-id
- pattern: '\bCUST-[0-9]{8}\b'
- confidence: high
-```
-
-File-backed:
-
-```yaml
-pattern_catalog: patterns.yaml
-```
-
-Relative paths resolve beneath Privacy Guard's working directory. The path must
-end in `.yaml` or `.yml`. Absolute paths, `..` traversal, and symlinks are
-rejected. Start Privacy Guard from the directory that contains the referenced
-catalog, or use a path relative to that directory.
-
-See [RegexEngine](engines/regex.md) for the complete catalog schema.
-
-## Inspect and validate configuration
-
-List the engines installed in the selected registry:
-
-```bash
-uv run privacy-guard engines
-```
-
-Print the exact JSON Schema accepted by that registry:
-
-```bash
-uv run privacy-guard configuration-schema
-```
-
-For a custom registry, pass the same factory to inspection and serving:
-
-```bash
-uv run privacy-guard \
- --registry-factory my_engines:create_registry \
- engines
-
-uv run privacy-guard \
- --registry-factory my_engines:create_registry \
- configuration-schema
-
-uv run privacy-guard \
- --registry-factory my_engines:create_registry \
- serve
-```
-
-Sandbox creation calls `ValidateConfig`. A successful creation proves that the
-middleware registration is reachable and that the supplied config matches the
-running registry.
-
-## Policy and deployment ownership
-
-Keep privacy behavior in policy:
-
-- stage order
-- entity definitions
-- detection settings
-- engine-specific replacement recipes
-- final action
-
-Keep operational resources in the Privacy Guard deployment:
-
-- installed engine implementations
-- model clients and SDK adapters
-- endpoints and credentials
-- approved model profiles
-- processing timeout
-
-A policy cannot select a registry factory or import Python code.
-
-## Configuration activation
-
-OpenShell sends the complete configuration on each evaluation. Privacy Guard
-validates it and compares the normalized immutable result with the active
-configuration:
-
-- equal configuration reuses the active processor
-- changed valid configuration is fully prepared, then atomically activated
-- failed validation or preparation leaves the active processor unchanged and
- fails the triggering evaluation
-
-Send one consistent configuration stream to each Privacy Guard process.
-Interleaving configurations causes the active processor to switch between them.
-
-The transport configuration is limited to 64 KiB. File-backed Regex catalogs
-carry only their relative path through the transport and are loaded by the
-Privacy Guard process.
-
-## Next steps
-
-- [RegexEngine](engines/regex.md)
-- [Add a custom engine](engines/custom.md)
-- [Run and operate Privacy Guard](operations.md)
-- [Limits and failure behavior](reference/limits-and-failures.md)
diff --git a/projects/privacy-guard/docs/engines/custom.md b/projects/privacy-guard/docs/engines/custom.md
deleted file mode 100644
index 990d1221..00000000
--- a/projects/privacy-guard/docs/engines/custom.md
+++ /dev/null
@@ -1,325 +0,0 @@
----
-title: Add a custom engine
-description: Implement, register, run, and test a typed Privacy Guard entity-processing engine.
-agent_markdown: true
----
-
-# Add a custom engine
-
-A custom engine integrates another detector or replacement tool with Privacy
-Guard. It receives one text string, an invocation strategy, a shared timeout,
-and validated engine-specific configuration. It returns processed text and
-bounded detections.
-
-Custom engine code runs inside the Privacy Guard process and can access request
-text. Install only reviewed, trusted implementations.
-
-## Engine contract
-
-A custom engine defines:
-
-1. a concrete `EngineConfig`
-2. optional typed `EngineResources`
-3. supported invocation strategies
-4. optional immutable initialization in `_initialize()`
-5. request processing in `_run()`
-
-Do not override `__init__()` or the public `run()` method. The framework-owned
-wrapper validates strategy support, timeouts, detection spans, detection
-cardinality, output size, and mutation behavior.
-
-## Minimal detection engine
-
-```python
-import re
-from typing import Literal
-
-from pydantic import Field
-
-from privacy_guard.engines import (
- EngineConfig,
- EntityDetection,
- EntityProcessingEngine,
- EntityProcessingStrategy,
- TextProcessingResult,
-)
-from privacy_guard.timeout import Timeout
-
-
-class KeywordEngineConfig(EngineConfig):
- engine: Literal["keyword"] = "keyword"
- keyword: str = Field(min_length=1, max_length=256)
-
-
-class KeywordEngine(EntityProcessingEngine[KeywordEngineConfig]):
- supported_strategies = frozenset(
- {EntityProcessingStrategy.DETECT}
- )
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- matches = re.finditer(re.escape(self.config.keyword), text)
- return TextProcessingResult.from_detections(
- text=text,
- detections=(
- EntityDetection(
- entity="keyword",
- start=match.start(),
- end=match.end(),
- )
- for match in matches
- ),
- )
-```
-
-`TextProcessingResult.from_detections()` stops consuming a lazy detection
-stream when the per-stage limit is exceeded. The public engine wrapper remains
-the enforcement boundary.
-
-## Configuration
-
-Each config class must:
-
-- subclass `EngineConfig`
-- declare one literal `engine` discriminator
-- use strict typed fields for all policy-owned behavior
-- reject unknown fields through the shared base model
-- keep sensitive values out of normal representations when applicable
-
-```python
-class AcmeEngineConfig(EngineConfig):
- engine: Literal["acme-pii"] = "acme-pii"
- model_profile: str
- replacement: AcmeReplacement | None = None
-```
-
-Privacy Guard adds the exact config type to the registry-built Pydantic
-discriminated union. The policy object is passed unchanged to the engine.
-
-OpenShell transports numbers through protobuf `Struct`. Integer settings must
-fit the safe range `-(2^53 - 1)` through `2^53 - 1`.
-
-## Operational resources
-
-Use `EngineResources` for deployment-owned clients, adapters, endpoints,
-credential providers, or preloaded models:
-
-```python
-from dataclasses import dataclass
-
-from privacy_guard.engines import EngineResources
-
-
-@dataclass(frozen=True)
-class AcmeResources(EngineResources):
- client: AcmeClient
-
-
-class AcmeEngine(
- EntityProcessingEngine[AcmeEngineConfig, AcmeResources]
-):
- ...
-```
-
-Resources must:
-
-- contain operational dependencies, not policy behavior
-- retain no request text or per-request state
-- be safe for concurrent use
-- be created before request processing
-
-A resource-free engine omits the second generic argument.
-
-## Supported strategies
-
-Declare the exact operations exposed by the engine:
-
-```python
-supported_strategies = frozenset(
- {
- EntityProcessingStrategy.DETECT,
- EntityProcessingStrategy.REPLACE,
- }
-)
-```
-
-`block` is not an engine strategy. The processor invokes `DETECT` and applies
-the block decision after successful engine execution.
-
-Override `_validate_run_config()` when a strategy requires additional
-configuration. For example, a replacement engine can require a replacement
-recipe only when invoked with `REPLACE`.
-
-## Result requirements
-
-Return `TextProcessingResult` with:
-
-| Field | Requirement |
-| --- | --- |
-| `text` | Complete authoritative stage output |
-| `detections` | Every bounded occurrence produced by the stage |
-
-Each `EntityDetection` provides:
-
-| Field | Requirement |
-| --- | --- |
-| `entity` | Stable declared identifier, never a value derived from request text |
-| `start` | Inclusive Unicode code-point offset in stage input |
-| `end` | Exclusive non-empty offset in stage input |
-| `confidence` | Optional `low`, `medium`, or `high` |
-| `metadata` | Optional bounded internal attribution |
-
-For `DETECT`, returned text must exactly equal input text. For `REPLACE`, text
-may change only when the result contains at least one detection. Do not return
-partial text or detections after a collaborator failure.
-
-## Timeouts
-
-One `Timeout` is shared across the complete stage pipeline. Pass its remaining
-duration to APIs that accept a timeout:
-
-```python
-result = client.process(
- text,
- timeout=timeout.remaining_seconds(),
-)
-```
-
-Translate Python `TimeoutError` with the shared context manager:
-
-```python
-with timeout.enforce():
- result = client.process(
- text,
- timeout=timeout.remaining_seconds(),
- )
-```
-
-Long-running local loops may call `timeout.raise_if_expired()`. Document and
-bound operations that cannot be interrupted.
-
-## Concurrency
-
-One configured engine instance may process requests concurrently. Keep request
-text, detections, counters, and temporary objects local to `_run()`. Treat
-configuration and derived initialization state as immutable. Ensure injected
-clients and resources support concurrent calls.
-
-## Errors and logging
-
-Translate expected collaborator failures into Privacy Guard's content-safe
-engine exceptions. Do not include:
-
-- input or replacement text
-- matched values or surrounding text
-- credentials or endpoints
-- raw exception messages
-- model or SDK response bodies
-
-Stable engine and entity identifiers may appear in findings and diagnostic
-logs when they satisfy shared validation.
-
-Use a static, content-safe message when translating an operational failure:
-
-```python
-from privacy_guard.engines import EngineExecutionError
-
-try:
- result = self.resources.client.process(
- text,
- timeout=timeout.remaining_seconds(),
- )
-except AcmeClientError:
- raise EngineExecutionError("Acme processing failed") from None
-```
-
-| Exception | Use in a custom engine |
-| --- | --- |
-| `EngineConfigurationError` | Strategy-specific configuration is unusable |
-| `EngineExecutionError` | A collaborator or runtime operation failed |
-| `EngineLimitExceededError` | Engine-owned bounded work or output exceeded its limit |
-
-The framework raises `EngineContractError` when returned text or detections
-violate the engine contract; custom engines should not use it for collaborator
-failures.
-
-## Register the engine
-
-Create one application-scoped registry factory:
-
-```python
-from privacy_guard.engines.registry import EngineRegistry
-
-
-def create_registry() -> EngineRegistry:
- registry = EngineRegistry(include_builtin_engines=True)
- registry.register(KeywordEngine)
- return registry.finalize()
-```
-
-Pass resources during registration when required:
-
-```python
-registry.register(
- AcmeEngine,
- resources=AcmeResources(client=client),
-)
-```
-
-Use `include_builtin_engines=True` to add the built-in `RegexEngine`. Omit it
-when the registry should contain only explicitly registered custom engines.
-
-## Inspect and run the registry
-
-```bash
-uv run privacy-guard \
- --registry-factory my_engines:create_registry \
- engines
-
-uv run privacy-guard \
- --registry-factory my_engines:create_registry \
- configuration-schema
-
-uv run privacy-guard \
- --registry-factory my_engines:create_registry \
- serve \
- --listen 0.0.0.0:50051
-```
-
-The module must be installed or available on `PYTHONPATH`. The factory is
-trusted deployment code and executes for each CLI invocation.
-
-The complete runnable example is in
-[`projects/privacy-guard/examples/custom-engine`](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/privacy-guard/examples/custom-engine).
-
-## Verify the integration
-
-Before deploying a custom engine:
-
-1. Run its unit tests directly against `run()` for every supported strategy.
- Assert the exact returned text, entity identifiers, spans, and confidence.
- Include Unicode input when offsets come from another library.
-2. Run `engines` and `configuration-schema` with the registry factory. Confirm
- that the engine and its policy fields appear.
-3. Send a representative request through a running Privacy Guard service with
- the deployment policy. Confirm the OpenShell decision, replacement body, and
- findings.
-4. Force collaborator timeouts and failures. Confirm that the request fails
- without partial output and that responses and logs contain no request text,
- credentials, or raw collaborator errors.
-
-Test engine-specific behavior and integrations. Privacy Guard's own suite
-covers the shared wrapper contract, processor ordering, request-wide limits,
-and gRPC result mapping.
-
-## Related pages
-
-- [Configure policies](../configuration.md)
-- [Run and operate Privacy Guard](../operations.md)
-- [System architecture](../architecture/index.md)
-- [Limits and failure behavior](../reference/limits-and-failures.md)
diff --git a/projects/privacy-guard/docs/engines/index.md b/projects/privacy-guard/docs/engines/index.md
deleted file mode 100644
index a20d970b..00000000
--- a/projects/privacy-guard/docs/engines/index.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-title: Engines
-description: Understand how Privacy Guard engines detect and replace sensitive entities.
-agent_markdown: true
----
-
-# Engines
-
-Engines are pluggable processors that inspect request text for configured
-entities. Each policy stage selects an engine, supplies its configuration, and
-receives detections plus replacement text when replacement is enabled.
-
-Engines do not decide whether Privacy Guard allows or denies a request. The
-request processor runs the configured stages in order, enforces shared safety
-bounds, and applies the policy's final action to their combined results.
-
-Privacy Guard includes two integration paths:
-
-- [RegexEngine](regex.md) provides deterministic detection and replacement
- using a deployment-defined pattern catalog.
-- [Custom engines](custom.md) integrate another detector, model, SDK, or
- service through Privacy Guard's engine contract and registry.
-
-Use the regex engine when the sensitive values have stable, testable formats.
-Add a custom engine when detection requires semantics or an external system
-that regular expressions cannot provide reliably.
diff --git a/projects/privacy-guard/docs/engines/regex.md b/projects/privacy-guard/docs/engines/regex.md
deleted file mode 100644
index 2c40a3a1..00000000
--- a/projects/privacy-guard/docs/engines/regex.md
+++ /dev/null
@@ -1,215 +0,0 @@
----
-title: RegexEngine
-description: Configure RegexEngine catalogs, matching flags, findings, and deterministic replacement.
-agent_markdown: true
----
-
-# RegexEngine
-
-`RegexEngine` is the built-in Privacy Guard engine. It detects every configured
-regular-expression match and can replace a deterministic non-overlapping subset
-with a constrained template.
-
-Privacy Guard provides the catalog schema and execution bounds. It does not
-provide an authoritative pattern catalog. Define and test patterns for the data
-your deployment handles.
-
-## Engine configuration
-
-```yaml
-engine: regex
-pattern_catalog: patterns.yaml
-replacement:
- strategy: template
- template: "[{entity}]"
-```
-
-| Field | Required | Purpose |
-| --- | --- | --- |
-| `engine` | Yes | Must be `regex` |
-| `pattern_catalog` | Yes | Inline catalog or relative YAML path |
-| `replacement` | For `replace` actions | Template replacement configuration |
-
-## Catalog structure
-
-```yaml
-entities:
- - name: email
- rules:
- - name: conventional-email
- pattern: '(? TextProcessingResult:
- matches = re.finditer(re.escape(self.config.keyword), text)
- return TextProcessingResult.from_detections(
- text=text,
- detections=(
- EntityDetection(
- entity=self.config.entity,
- start=match.start(),
- end=match.end(),
- confidence=ConfidenceLevel.HIGH,
- )
- for match in matches
- ),
- )
-
-
-def create_registry() -> EngineRegistry:
- """Create a registry containing the built-in and custom engines."""
- registry = EngineRegistry(include_builtin_engines=True)
- registry.register(KeywordEngine)
- return registry.finalize()
diff --git a/projects/privacy-guard/examples/custom-engine/privacy-guard-config.yaml b/projects/privacy-guard/examples/custom-engine/privacy-guard-config.yaml
deleted file mode 100644
index 1ee1d76c..00000000
--- a/projects/privacy-guard/examples/custom-engine/privacy-guard-config.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-entity_processing:
- stages:
- - name: project-names
- config:
- engine: keyword-tool
- entity: confidential-project
- keyword: Project Cobalt
-on_detection:
- action: detect
diff --git a/projects/privacy-guard/examples/regex-engine/.gitignore b/projects/privacy-guard/examples/regex-engine/.gitignore
deleted file mode 100644
index b223234c..00000000
--- a/projects/privacy-guard/examples/regex-engine/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-gateway.local.toml
diff --git a/projects/privacy-guard/examples/regex-engine/README.md b/projects/privacy-guard/examples/regex-engine/README.md
deleted file mode 100644
index 27ca6375..00000000
--- a/projects/privacy-guard/examples/regex-engine/README.md
+++ /dev/null
@@ -1,177 +0,0 @@
-# RegexEngine end-to-end example
-
-This example runs Privacy Guard's built-in `RegexEngine` through OpenShell. The
-final check sends a Claude Code request containing an email address and customer
-ID, then verifies that OpenShell forwards `[email]` and `[customer-id]`.
-
-Privacy Guard does not ship authoritative regex presets. Copy and adapt
-`patterns.yaml` for the data you actually need to identify, and test every
-pattern against representative matches, non-matches, and worst-case inputs
-before deployment.
-
-## Prerequisites
-
-This walkthrough was validated with OpenShell `v0.0.90`, the version recorded
-in Privacy Guard's `.openshell-middleware-manifest.json`. A later OpenShell
-release can also work if it supports the same supervisor middleware contract
-and policy schema.
-
-Before you start, install:
-
-- Python 3.11 or newer and `uv` 0.11 or newer
-- [OpenShell](https://github.com/NVIDIA/OpenShell) `v0.0.90` or a later
- compatible version
-
-The gateway lifecycle commands below cover macOS Homebrew and Linux Debian/RPM
-installations. For another deployment, use its equivalent gateway commands.
-
-## Stop the local gateway
-
-First, check the local gateway:
-
-```bash
-openshell status
-```
-
-If the gateway is running, stop it before you change its configuration. Use the
-command for your system:
-
-```bash
-# macOS with Homebrew
-brew services stop openshell
-
-# Linux with a Debian or RPM package
-systemctl --user stop openshell-gateway
-```
-
-## Start Privacy Guard
-
-In terminal 1, from this example directory:
-
-```bash
-cd projects/privacy-guard/examples/regex-engine
-uv run --locked privacy-guard serve --listen 0.0.0.0:50051
-```
-
-Leave this terminal running. The development server is unauthenticated
-plaintext gRPC and receives potentially sensitive request bodies. Binding to
-`0.0.0.0` is necessary for the sandbox supervisor to reach it, but port 50051
-must remain restricted to the host and trusted sandbox network.
-
-## Configure and start the gateway
-
-Choose a non-loopback host IPv4 address that both the gateway and sandbox
-supervisor can reach.
-
-In terminal 2, return to the example directory. Replace `YOUR_HOST_IPV4` with
-the address you selected, then update the default gateway configuration:
-
-```bash
-cd projects/privacy-guard/examples/regex-engine
-uv run privacy-guard add-gateway-registration \
- --host-ip YOUR_HOST_IPV4 \
- --name privacy-guard-regex
-```
-
-Do not use `127.0.0.1`, a VPN address, or `host.openshell.internal`. The gateway
-and sandbox supervisor must both be able to reach the configured endpoint.
-
-Next, use the command for your system to start the gateway in the background:
-
-```bash
-# macOS with Homebrew
-brew services start openshell
-
-# Linux with a Debian or RPM package
-systemctl --user start openshell-gateway
-```
-
-## Verify OpenShell and create the sandbox
-
-In terminal 3, from this example directory:
-
-```bash
-openshell status
-```
-
-Do not continue until status reports that the gateway is connected.
-
-This walkthrough starts Claude Code in the sandbox. To use a different harness,
-replace everything after `--` with its command. Then create the sandbox:
-
-```bash
-openshell sandbox create \
- --name privacy-guard-regex \
- --from base \
- --no-auto-providers \
- --policy "$PWD/policy.yaml" \
- -- env CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude
-```
-
-Sandbox creation validates the external middleware registration and the exact
-`RegexEngineConfig` embedded in the policy.
-
-After authenticating Claude Code, enter:
-
-```text
-Draft a short greeting for user@example.com about customer CUST-12345678.
-```
-
-Privacy Guard should send `[email]` and `[customer-id]` instead of the original
-identifiers to the provider.
-
-## Verify the middleware result
-
-From another host terminal:
-
-```bash
-openshell logs privacy-guard-regex -n 100 --source sandbox
-```
-
-Look for the `api.anthropic.com/v1/messages` request with `transformed:true`,
-plus `email (identifiers)` and `customer-id (identifiers)` findings. Findings
-must not contain the matched email address or customer ID.
-
-## Cleanup
-
-Exit Claude and delete the sandbox:
-
-```bash
-openshell sandbox delete privacy-guard-regex
-```
-
-Stop Privacy Guard with `Ctrl-C`, then stop the gateway before removing the
-example registration:
-
-```bash
-# macOS with Homebrew
-brew services stop openshell
-
-# Linux with a Debian or RPM package
-systemctl --user stop openshell-gateway
-
-uv run privacy-guard remove-gateway-registration \
- --name privacy-guard-regex
-```
-
-Restart the gateway with the command for your system, then verify its
-connection:
-
-```bash
-# macOS with Homebrew
-brew services start openshell
-
-# Linux with a Debian or RPM package
-systemctl --user start openshell-gateway
-
-openshell status
-```
-
-## Troubleshooting
-
-- Sandbox creation reports unavailable middleware: confirm terminal 1 is still
- running, check the IP in the default gateway configuration, and allow trusted
- sandbox traffic to host port 50051.
-- Policy or middleware registration fields are rejected: confirm that
- `openshell` and `openshell-gateway` use compatible versions. If the error
- remains, use the tested `v0.0.90` release.
diff --git a/projects/privacy-guard/examples/regex-engine/privacy-guard-config.yaml b/projects/privacy-guard/examples/regex-engine/privacy-guard-config.yaml
deleted file mode 100644
index 219c5fb6..00000000
--- a/projects/privacy-guard/examples/regex-engine/privacy-guard-config.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-entity_processing:
- stages:
- - name: identifiers
- config:
- engine: regex
- pattern_catalog: patterns.yaml
- replacement:
- strategy: template
- template: "[{entity}]"
-on_detection:
- action: replace
diff --git a/projects/privacy-guard/src/privacy_guard/__init__.py b/projects/privacy-guard/src/privacy_guard/__init__.py
deleted file mode 100644
index da81cd3f..00000000
--- a/projects/privacy-guard/src/privacy_guard/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Privacy Guard: an OpenShell supervisor middleware. See the package README."""
diff --git a/projects/privacy-guard/src/privacy_guard/cli.py b/projects/privacy-guard/src/privacy_guard/cli.py
deleted file mode 100644
index 773b898c..00000000
--- a/projects/privacy-guard/src/privacy_guard/cli.py
+++ /dev/null
@@ -1,381 +0,0 @@
-"""Privacy Guard command-line application."""
-
-from __future__ import annotations
-
-import importlib
-import ipaddress
-import json
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Annotated
-
-import typer
-
-from privacy_guard.constants import DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS
-from privacy_guard.engines import EntityProcessingStrategy
-from privacy_guard.engines.registry import EngineRegistry, create_builtin_registry
-from privacy_guard.errors import PrivacyGuardError
-from privacy_guard.gateway_config import (
- MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES,
- GatewayConfigError,
- GatewayConfigRemoval,
- GatewayConfigUpdate,
- default_gateway_config_path,
- remove_gateway_config,
- update_gateway_config,
- validate_middleware_name,
-)
-from privacy_guard.logging import LoggingConfig, configure_logging, get_logger
-from privacy_guard.service.server import DEFAULT_LISTEN_ADDRESS, PrivacyGuardServer
-from privacy_guard.timeout import validate_timeout_seconds
-
-app = typer.Typer(
- name="privacy-guard",
- help=(
- "Run Privacy Guard, manage local OpenShell gateway registrations, and "
- "inspect installed entity-processing engines."
- ),
- no_args_is_help=True,
- add_completion=False,
-)
-
-
-@app.callback()
-def configure_cli(
- context: typer.Context,
- registry_factory: Annotated[
- str | None,
- typer.Option(
- help=(
- "Load engines from a trusted Python callable, formatted as "
- "module:factory. The callable must return a finalized EngineRegistry."
- ),
- ),
- ] = None,
- debug: Annotated[
- bool,
- typer.Option(
- "--debug",
- help=(
- "Log content-safe diagnostic details for startup and request handling."
- ),
- ),
- ] = False,
- debug_log_content: Annotated[
- bool,
- typer.Option(
- "--debug-log-content",
- help=(
- "DANGEROUS: log complete request and processed text, which may "
- "contain secrets or personal data."
- ),
- ),
- ] = False,
-) -> None:
- """Configure the command application and its engine 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(
- "privacy_guard_request_content_logging_enabled "
- "complete_request_text_may_contain_secrets"
- )
-
-
-@app.command("serve")
-def serve(
- context: typer.Context,
- listen: Annotated[
- str,
- typer.Option(
- help=(
- "Host and port on which Privacy Guard listens, formatted as "
- "host:port. Use 0.0.0.0 when sandbox supervisors must reach it."
- ),
- ),
- ] = DEFAULT_LISTEN_ADDRESS,
- timeout_seconds: Annotated[
- float,
- typer.Option(
- help=(
- "Maximum seconds shared by all processing stages in one request; "
- f"must be greater than 0 and at most {MAX_TIMEOUT_SECONDS:g}."
- ),
- ),
- ] = DEFAULT_TIMEOUT_SECONDS,
-) -> None:
- """Run Privacy Guard until the process receives a shutdown signal."""
- 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:
- PrivacyGuardServer(
- options.registry,
- timeout_seconds=validated_timeout_seconds,
- log_request_content=options.log_request_content,
- ).serve_sync(listen)
- except PrivacyGuardError as error:
- typer.echo(str(error), err=True)
- raise typer.Exit(code=1) from None
-
-
-@app.command("add-gateway-registration")
-def add_gateway_registration(
- host_ip: Annotated[
- str,
- typer.Option(
- help=(
- "Non-loopback IPv4 address of this host that both the OpenShell "
- "gateway and sandbox supervisors can reach."
- ),
- ),
- ],
- config: Annotated[
- Path | None,
- typer.Option(
- help=(
- "Gateway TOML to update. Defaults to "
- "`$OPENSHELL_GATEWAY_CONFIG` when set, otherwise `gateway.toml` "
- "under `$XDG_CONFIG_HOME/openshell`."
- ),
- ),
- ] = None,
- name: Annotated[
- str,
- typer.Option(
- help=(
- "Gateway registration name referenced by the policy's middleware "
- "field. OpenShell allows "
- f"1-{MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES} ASCII bytes."
- ),
- ),
- ] = "privacy-guard",
- port: Annotated[
- int,
- typer.Option(
- min=1,
- max=65535,
- help=(
- "Privacy Guard port. Use the same port in `privacy-guard serve "
- "--listen`."
- ),
- ),
- ] = 50051,
-) -> None:
- """Add or update Privacy Guard in an OpenShell gateway TOML file."""
- try:
- address = ipaddress.IPv4Address(host_ip)
- except ipaddress.AddressValueError:
- raise typer.BadParameter(
- "Pass one IPv4 address, for example --host-ip 192.168.1.20.",
- param_hint="--host-ip",
- ) from None
- if address.is_loopback or address.is_unspecified:
- raise typer.BadParameter(
- "Pass a non-loopback host IPv4 address reachable by sandbox "
- "supervisors; do not use 127.0.0.1 or 0.0.0.0.",
- param_hint="--host-ip",
- )
- try:
- validated_name = validate_middleware_name(name)
- except GatewayConfigError as error:
- raise typer.BadParameter(
- str(error),
- param_hint="--name",
- ) from None
-
- config_path = config or default_gateway_config_path()
- try:
- result = update_gateway_config(
- config_path,
- middleware_name=validated_name,
- host_ip=str(address),
- port=port,
- )
- except GatewayConfigError as error:
- typer.echo(
- f"Could not add or update the OpenShell gateway registration: {error}",
- err=True,
- )
- 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",
- }[result]
- typer.echo(f"{action} {config_path}")
- typer.echo(f"Registered {validated_name} at http://{address}:{port}")
- typer.echo(
- "Next: start Privacy Guard, then restart the OpenShell gateway so it "
- "loads this registration."
- )
-
-
-@app.command("remove-gateway-registration")
-def remove_gateway_registration(
- name: Annotated[
- str,
- typer.Option(
- help=(
- "Gateway registration name to remove. OpenShell allows "
- f"1-{MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES} ASCII bytes."
- ),
- ),
- ],
- config: Annotated[
- Path | None,
- typer.Option(
- help=(
- "Gateway TOML to update. Defaults to "
- "`$OPENSHELL_GATEWAY_CONFIG` when set, otherwise `gateway.toml` "
- "under `$XDG_CONFIG_HOME/openshell`."
- ),
- ),
- ] = 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,
- )
- except GatewayConfigError as error:
- typer.echo(
- f"Could not remove the OpenShell gateway registration: {error}",
- err=True,
- )
- 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."
- )
- 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 engines."""
- typer.echo(
- json.dumps(
- _command_options(context).registry.configuration_json_schema(),
- indent=2,
- ensure_ascii=False,
- sort_keys=True,
- )
- )
-
-
-@app.command("engines")
-def engines(context: typer.Context) -> None:
- """List installed engines, supported strategies, and their behavior."""
- for description in _command_options(context).registry.describe_engines():
- strategies = ",".join(
- strategy.value
- for strategy in EntityProcessingStrategy
- if strategy in description.supported_strategies
- )
- typer.echo(
- f"{description.engine_name}\t{strategies}\t{description.description}"
- )
-
-
-_LOGGER = get_logger(__name__)
-
-
-@dataclass(frozen=True)
-class _CommandOptions:
- registry: EngineRegistry
- log_request_content: bool
-
-
-def _load_registry(factory_reference: str | None) -> EngineRegistry:
- 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:
- raise typer.BadParameter(
- "Use module:factory, for example my_engines:create_registry.",
- param_hint="--registry-factory",
- )
- try:
- 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.",
- 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.",
- 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 EngineRegistry.",
- 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.",
- param_hint="--registry-factory",
- ) from None
- if not isinstance(registry, EngineRegistry):
- raise typer.BadParameter(
- "Registry factory returned an invalid object. Return an EngineRegistry.",
- param_hint="--registry-factory",
- )
- if not registry.is_finalized:
- raise typer.BadParameter(
- "Registry factory returned an unfinalized registry. Call finalize() "
- "before returning it.",
- param_hint="--registry-factory",
- )
- return registry
-
-
-def _command_options(context: typer.Context) -> _CommandOptions:
- options = context.obj
- if not isinstance(options, _CommandOptions):
- raise RuntimeError("Privacy Guard command context is unavailable")
- return options
-
-
-if __name__ == "__main__":
- app()
-
-
-__all__ = ["app"]
diff --git a/projects/privacy-guard/src/privacy_guard/config.py b/projects/privacy-guard/src/privacy_guard/config.py
deleted file mode 100644
index 0862880f..00000000
--- a/projects/privacy-guard/src/privacy_guard/config.py
+++ /dev/null
@@ -1,120 +0,0 @@
-"""Strict entity-processing policy configuration.
-
-The concrete model accepted at the policy boundary is finalized by
-``EngineRegistry``. Its stage ``config`` field is a Pydantic discriminated
-union containing the exact config model registered by every engine.
-"""
-
-from __future__ import annotations
-
-from enum import StrEnum
-from typing import Generic, Self, TypeVar
-
-from pydantic import (
- Field,
- field_validator,
- model_validator,
-)
-
-from privacy_guard.base import StrictDomainModel
-from privacy_guard.constants import MAX_ENTITY_PROCESSING_STAGES
-from privacy_guard.engines import EngineConfig
-from privacy_guard.string_validators import (
- BoundedMetadataString,
- validate_scalar_string,
-)
-
-
-class PolicyAction(StrEnum):
- """User-facing disposition applied after all configured stages run."""
-
- DETECT = "detect"
- BLOCK = "block"
- REPLACE = "replace"
-
-
-class OnDetection(StrictDomainModel):
- """Required policy disposition for detected entities."""
-
- action: PolicyAction
-
- @field_validator("action", mode="before")
- @classmethod
- def _parse_action(cls, value: object) -> PolicyAction:
- if isinstance(value, PolicyAction):
- return value
- return PolicyAction(validate_scalar_string(value))
-
-
-_EngineConfigT = TypeVar(
- "_EngineConfigT",
- bound=EngineConfig,
-)
-
-
-class EntityProcessingStage(
- StrictDomainModel,
- Generic[_EngineConfigT],
-):
- """One ordered invocation of an engine with an optional diagnostic name."""
-
- name: BoundedMetadataString | None = None
- config: _EngineConfigT = Field(repr=False)
-
- def diagnostic_name(self, stage_number: int) -> str:
- """Return the explicit name or a deterministic one-based source label."""
- if self.name is not None:
- return self.name
- if isinstance(stage_number, bool) or stage_number < 1:
- raise ValueError("stage number must be a positive integer")
- engine = getattr(self.config, "engine", None)
- if not isinstance(engine, str):
- raise ValueError("stage config has no engine discriminator")
- return f"{engine}[{stage_number}]"
-
-
-class EntityProcessingStages(
- StrictDomainModel,
- Generic[_EngineConfigT],
-):
- """The ordered entity-processing stages for one policy."""
-
- stages: tuple[EntityProcessingStage[_EngineConfigT], ...] = Field(repr=False)
-
- @field_validator("stages", mode="before")
- @classmethod
- def _parse_stages(cls, value: object) -> object:
- if not isinstance(value, list | tuple) or not value:
- raise ValueError("stages must be a non-empty list")
- if len(value) > MAX_ENTITY_PROCESSING_STAGES:
- raise ValueError("policy has too many entity-processing stages")
- return tuple(value)
-
- @model_validator(mode="after")
- def _diagnostic_names_are_unique(self) -> Self:
- names = [
- stage.diagnostic_name(index)
- for index, stage in enumerate(self.stages, start=1)
- ]
- if len(names) != len(set(names)):
- raise ValueError("stage diagnostic names must be unique")
- return self
-
-
-class PrivacyGuardConfig(
- StrictDomainModel,
- Generic[_EngineConfigT],
-):
- """Complete validated Privacy Guard behavior for one OpenShell policy."""
-
- entity_processing: EntityProcessingStages[_EngineConfigT] = Field(repr=False)
- on_detection: OnDetection = Field(repr=False)
-
-
-__all__ = [
- "EntityProcessingStage",
- "EntityProcessingStages",
- "OnDetection",
- "PolicyAction",
- "PrivacyGuardConfig",
-]
diff --git a/projects/privacy-guard/src/privacy_guard/engines/__init__.py b/projects/privacy-guard/src/privacy_guard/engines/__init__.py
deleted file mode 100644
index 59a843d6..00000000
--- a/projects/privacy-guard/src/privacy_guard/engines/__init__.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""Supported entity-processing extension and built-in regex engine surface."""
-
-from __future__ import annotations
-
-from privacy_guard.engines.base import (
- BoundedMetadata,
- ConfidenceLevel,
- EngineConfig,
- EngineResources,
- EntityDetection,
- EntityName,
- EntityProcessingEngine,
- EntityProcessingStrategy,
- TextProcessingResult,
-)
-from privacy_guard.engines.regex import (
- RegexEngine,
- RegexEngineConfig,
- RegexEntity,
- RegexPatternCatalog,
- RegexReplacement,
- RegexRule,
-)
-from privacy_guard.errors import (
- EngineConfigurationError,
- EngineContractError,
- EngineExecutionError,
- EngineLimitExceededError,
- EntityProcessingError,
-)
-
-__all__ = [
- "BoundedMetadata",
- "ConfidenceLevel",
- "EngineConfig",
- "EngineConfigurationError",
- "EngineContractError",
- "EngineExecutionError",
- "EngineLimitExceededError",
- "EngineResources",
- "EntityDetection",
- "EntityName",
- "EntityProcessingEngine",
- "EntityProcessingError",
- "EntityProcessingStrategy",
- "RegexEngine",
- "RegexEngineConfig",
- "RegexEntity",
- "RegexPatternCatalog",
- "RegexReplacement",
- "RegexRule",
- "TextProcessingResult",
-]
diff --git a/projects/privacy-guard/src/privacy_guard/engines/base.py b/projects/privacy-guard/src/privacy_guard/engines/base.py
deleted file mode 100644
index c6d60bfe..00000000
--- a/projects/privacy-guard/src/privacy_guard/engines/base.py
+++ /dev/null
@@ -1,367 +0,0 @@
-"""Core entity-processing engine extension contract."""
-
-from __future__ import annotations
-
-from abc import ABC, abstractmethod
-from collections.abc import Iterable, Mapping
-from enum import StrEnum
-from itertools import islice
-from types import MappingProxyType
-from typing import (
- Annotated,
- ClassVar,
- Generic,
- Self,
- TypeAlias,
- final,
- get_args,
- get_origin,
-)
-
-from pydantic import (
- BeforeValidator,
- Field,
- ValidationError,
- field_validator,
- model_validator,
-)
-from typing_extensions import TypeVar
-
-from privacy_guard.base import StrictDomainModel
-from privacy_guard.constants import (
- MAX_BODY_BYTES,
- MAX_DETECTIONS_PER_STAGE,
- MAX_FINDING_METADATA_ENTRIES,
-)
-from privacy_guard.errors import (
- EngineConfigurationError,
- EngineContractError,
- EngineLimitExceededError,
-)
-from privacy_guard.string_validators import (
- ScalarString,
- validate_bounded_metadata_string,
- validate_scalar_string,
-)
-from privacy_guard.timeout import Timeout
-
-
-class EntityProcessingStrategy(StrEnum):
- """Select whether one engine invocation detects or replaces entities."""
-
- DETECT = "detect"
- REPLACE = "replace"
-
-
-class ConfidenceLevel(StrEnum):
- """Categorical certainty reported by an entity-processing engine."""
-
- LOW = "low"
- MEDIUM = "medium"
- HIGH = "high"
-
-
-EntityName = Annotated[str, BeforeValidator(validate_bounded_metadata_string)]
-MetadataString = Annotated[str, BeforeValidator(validate_bounded_metadata_string)]
-BoundedMetadata: TypeAlias = Mapping[MetadataString, MetadataString]
-
-
-class EntityDetection(StrictDomainModel):
- """One sensitive entity occurrence in the engine's input text."""
-
- entity: EntityName
- start: int = Field(ge=0)
- end: int
- confidence: ConfidenceLevel | None = None
- metadata: BoundedMetadata = Field(default_factory=dict, repr=False)
-
- @field_validator("confidence", mode="before")
- @classmethod
- def _parse_confidence(cls, value: object) -> object:
- if isinstance(value, str):
- return ConfidenceLevel(validate_scalar_string(value))
- return value
-
- @field_validator("metadata")
- @classmethod
- def _copy_bounded_metadata(cls, value: Mapping[str, str]) -> Mapping[str, str]:
- if len(value) > MAX_FINDING_METADATA_ENTRIES:
- raise ValueError("detection metadata has too many entries")
- copied: dict[str, str] = {}
- for key, item in value.items():
- copied[validate_bounded_metadata_string(key)] = (
- validate_bounded_metadata_string(item)
- )
- return MappingProxyType(copied)
-
- @model_validator(mode="after")
- def _span_is_non_empty(self) -> EntityDetection:
- if self.end <= self.start:
- raise ValueError("detection span must be non-empty")
- return self
-
-
-class TextProcessingResult(StrictDomainModel):
- """The authoritative text and detections returned by one engine run."""
-
- text: ScalarString = Field(repr=False)
- detections: tuple[EntityDetection, ...]
-
- @field_validator("detections", mode="before")
- @classmethod
- def _detections_are_a_tuple(cls, value: object) -> object:
- if not isinstance(value, tuple):
- raise ValueError("detections must be a tuple")
- return value
-
- @classmethod
- def from_detections(
- cls,
- *,
- text: str,
- detections: Iterable[EntityDetection],
- ) -> Self:
- """Safely materialize a lazy stream; ``run()`` still validates the result."""
- bounded = tuple(islice(detections, MAX_DETECTIONS_PER_STAGE + 1))
- if len(bounded) > MAX_DETECTIONS_PER_STAGE:
- raise EngineLimitExceededError("engine returned too many detections")
- return cls(text=text, detections=bounded)
-
-
-class EngineConfig(StrictDomainModel):
- """Nominal base for an engine's exact policy configuration."""
-
-
-class EngineResources:
- """Optional operator-owned runtime dependencies shared by engine instances.
-
- Resource objects contain initialized operational dependencies such as model
- clients, SDK adapters, endpoints, or credential providers. They must not
- contain policy behavior or mutable per-request state, and everything they
- expose to an engine must be safe for concurrent use.
- """
-
- __slots__ = ()
-
-
-_ConfigT = TypeVar("_ConfigT", bound=EngineConfig)
-_ResourcesT = TypeVar(
- "_ResourcesT",
- bound=EngineResources | None,
- default=None,
-)
-
-
-class EntityProcessingEngine(ABC, Generic[_ConfigT, _ResourcesT]):
- """Nominal, typed extension point for processing one text string."""
-
- supported_strategies: ClassVar[frozenset[EntityProcessingStrategy]]
-
- @final
- def __init__(
- self,
- config: _ConfigT,
- resources: _ResourcesT,
- ) -> None:
- """Validate typed configuration/resources and initialize reusable state."""
- self.validate_config(config, resources)
- self.__config = config
- self.__resources = resources
- self._initialize()
-
- @classmethod
- def validate_config(
- cls,
- config: _ConfigT,
- resources: _ResourcesT,
- ) -> None:
- """Purely validate one exact config and its registered resources."""
- cls._validate_class_contract()
- config_type = cls.get_config_type()
- try:
- if not isinstance(config, config_type):
- raise ValueError
- config_type.model_validate(config)
- except (ValidationError, ValueError):
- raise EngineConfigurationError("engine configuration is invalid") from None
- resources_type = cls.get_resources_type()
- if not _is_valid_resources(resources, resources_type):
- raise EngineConfigurationError("engine resources are invalid")
- cls._validate_config(config, resources)
-
- @classmethod
- def validate_run_config(
- cls,
- config: _ConfigT,
- resources: _ResourcesT,
- *,
- strategy: EntityProcessingStrategy,
- ) -> None:
- """Validate that one config can execute the requested strategy."""
- if not isinstance(strategy, EntityProcessingStrategy):
- raise EngineConfigurationError("engine processing strategy is invalid")
- cls.validate_config(config, resources)
- if strategy not in cls.supported_strategies:
- raise EngineConfigurationError(
- "engine does not support the requested strategy"
- )
- cls._validate_run_config(config, resources, strategy=strategy)
-
- @classmethod
- def get_config_type(cls) -> type[EngineConfig]:
- """Return the concrete ``EngineConfig`` type argument."""
- config_type, _ = _declared_engine_types(cls)
- return config_type
-
- @classmethod
- def get_resources_type(cls) -> object:
- """Return the concrete runtime-resources generic argument."""
- _, resources_type = _declared_engine_types(cls)
- return resources_type
-
- @property
- def config(self) -> _ConfigT:
- """Return the immutable, concrete engine configuration."""
- return self.__config
-
- @property
- def resources(self) -> _ResourcesT:
- """Return the validated, injected runtime resources."""
- return self.__resources
-
- @final
- def run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- """Process one text value and validate the complete collaborator result."""
- try:
- validated_text = validate_scalar_string(text)
- except ValueError:
- raise EngineContractError("engine input text is invalid") from None
- if not isinstance(strategy, EntityProcessingStrategy):
- raise EngineContractError("engine processing strategy is invalid")
- if not isinstance(timeout, Timeout):
- raise EngineContractError("engine timeout is invalid")
- if strategy not in self.supported_strategies:
- raise EngineContractError("engine does not support the requested strategy")
- timeout.raise_if_expired()
- result: object = self._run(
- validated_text,
- strategy=strategy,
- timeout=timeout,
- )
- timeout.raise_if_expired()
- return _validate_result(validated_text, result, strategy=strategy)
-
- @classmethod
- def _validate_config(
- cls,
- config: _ConfigT,
- resources: _ResourcesT,
- ) -> None:
- """Optionally validate resource-backed config without side effects."""
-
- @classmethod
- def _validate_run_config(
- cls,
- config: _ConfigT,
- resources: _ResourcesT,
- *,
- strategy: EntityProcessingStrategy,
- ) -> None:
- """Optionally validate requirements specific to one run strategy."""
-
- @classmethod
- def _validate_class_contract(cls) -> None:
- supported_strategies = getattr(cls, "supported_strategies", None)
- if (
- not isinstance(supported_strategies, frozenset)
- or not supported_strategies
- or any(
- not isinstance(strategy, EntityProcessingStrategy)
- for strategy in supported_strategies
- )
- ):
- raise EngineConfigurationError("engine supported strategies are invalid")
- cls.get_config_type()
- cls.get_resources_type()
-
- def _initialize(self) -> None:
- """Optionally initialize reusable state from config and resources."""
-
- @abstractmethod
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- """Return processed text and every detected entity occurrence."""
- raise NotImplementedError
-
-
-def _declared_engine_types(
- engine_type: type[object],
-) -> tuple[type[EngineConfig], object]:
- for candidate in engine_type.__mro__:
- for base in getattr(candidate, "__orig_bases__", ()):
- if get_origin(base) is not EntityProcessingEngine:
- continue
- arguments = get_args(base)
- if len(arguments) != 2:
- break
- config_type, resources_type = arguments
- if isinstance(config_type, type) and issubclass(config_type, EngineConfig):
- return config_type, resources_type
- raise EngineConfigurationError(
- "engine must declare concrete configuration and resource types"
- )
-
-
-def _is_valid_resources(resources: object, resources_type: object) -> bool:
- if resources_type in (None, type(None)):
- return resources is None
- origin = get_origin(resources_type)
- if origin is not None:
- resources_type = origin
- return isinstance(resources_type, type) and isinstance(resources, resources_type)
-
-
-def _validate_result(
- input_text: str,
- result: object,
- *,
- strategy: EntityProcessingStrategy,
-) -> TextProcessingResult:
- if not isinstance(result, TextProcessingResult):
- raise EngineContractError("engine output is invalid")
- if len(result.detections) > MAX_DETECTIONS_PER_STAGE:
- raise EngineLimitExceededError("engine returned too many detections")
- if len(result.text.encode("utf-8")) > MAX_BODY_BYTES:
- raise EngineLimitExceededError("engine output text exceeds the size limit")
- for detection in result.detections:
- if detection.end > len(input_text):
- raise EngineContractError("engine detection span is invalid")
- if strategy is EntityProcessingStrategy.DETECT and result.text != input_text:
- raise EngineContractError("detection-only engine output changed text")
- if result.text != input_text and not result.detections:
- raise EngineContractError("engine changed text without a detection")
- return result
-
-
-__all__ = [
- "BoundedMetadata",
- "ConfidenceLevel",
- "EngineConfig",
- "EngineResources",
- "EntityDetection",
- "EntityName",
- "EntityProcessingEngine",
- "EntityProcessingStrategy",
- "TextProcessingResult",
-]
diff --git a/projects/privacy-guard/src/privacy_guard/engines/registry.py b/projects/privacy-guard/src/privacy_guard/engines/registry.py
deleted file mode 100644
index bee9deba..00000000
--- a/projects/privacy-guard/src/privacy_guard/engines/registry.py
+++ /dev/null
@@ -1,318 +0,0 @@
-"""Engine registration and finalized policy-schema construction."""
-
-from __future__ import annotations
-
-import inspect
-import re
-from collections.abc import Mapping, Sequence
-from dataclasses import dataclass
-from functools import reduce
-from operator import or_
-from types import NoneType
-from typing import Annotated, Literal, Self, get_args, get_origin
-
-from pydantic import Field, TypeAdapter, ValidationError
-from pydantic_core import PydanticUndefined
-
-from privacy_guard.config import (
- PolicyAction,
- PrivacyGuardConfig,
-)
-from privacy_guard.engines.base import (
- EngineConfig,
- EngineResources,
- EntityProcessingEngine,
- EntityProcessingStrategy,
-)
-from privacy_guard.engines.regex import (
- RegexEngine,
-)
-from privacy_guard.errors import (
- EngineConfigurationError,
- EngineRegistryError,
- ErrorCode,
- PrivacyGuardError,
-)
-
-
-@dataclass(frozen=True)
-class EngineDescription:
- """Safe discovery metadata for one registered engine."""
-
- engine_name: str
- description: str
- supported_strategies: frozenset[EntityProcessingStrategy]
-
-
-class EngineRegistry:
- """Register engine implementations and finalize their exact policy union."""
-
- def __init__(self, *, include_builtin_engines: bool = False) -> None:
- self._registrations: dict[str, _Registration] = {}
- self._config_adapter: TypeAdapter[PrivacyGuardConfig[EngineConfig]] | None = (
- None
- )
- if include_builtin_engines:
- self.register(RegexEngine)
-
- @property
- def is_finalized(self) -> bool:
- return self._config_adapter is not None
-
- def register(
- self,
- engine_type: type[object],
- *,
- resources: object = None,
- ) -> None:
- """Register one engine implementation and its operator-owned resources."""
- if self.is_finalized:
- raise EngineRegistryError("cannot register after finalization")
- if not isinstance(engine_type, type) or not issubclass(
- engine_type, EntityProcessingEngine
- ):
- raise EngineRegistryError("registered engine type is invalid")
- if engine_type.__init__ is not EntityProcessingEngine.__init__:
- raise EngineRegistryError(
- "engine lifecycle contract requires EntityProcessingEngine.__init__; "
- "use _initialize() instead"
- )
- if engine_type.run is not EntityProcessingEngine.run:
- raise EngineRegistryError(
- "engine lifecycle contract requires EntityProcessingEngine.run; "
- "implement _run() instead"
- )
-
- try:
- config_type = engine_type.get_config_type()
- resources_type = engine_type.get_resources_type()
- except (AttributeError, TypeError):
- raise EngineRegistryError("engine generic declaration is invalid") from None
- if not isinstance(config_type, type) or not issubclass(
- config_type, EngineConfig
- ):
- raise EngineRegistryError("engine config type is invalid")
- resources_runtime_type = (
- NoneType
- if resources_type is None
- else get_origin(resources_type) or resources_type
- )
- if not isinstance(resources_runtime_type, type):
- raise EngineRegistryError("engine resources type is invalid")
- if resources_runtime_type is not NoneType and not issubclass(
- resources_runtime_type,
- EngineResources,
- ):
- raise EngineRegistryError(
- "engine resources type must extend EngineResources"
- )
-
- engine_name = _engine_discriminator(config_type)
- if engine_name in self._registrations:
- raise EngineRegistryError("engine discriminator is already registered")
- if any(
- registration.config_type is config_type
- for registration in self._registrations.values()
- ):
- raise EngineRegistryError("engine config type is already registered")
-
- _supported_strategies(engine_type)
- if resources_runtime_type is NoneType:
- if resources is not None:
- raise EngineRegistryError("resource-free engine received resources")
- else:
- if resources is not None and not isinstance(resources, EngineResources):
- raise EngineRegistryError(
- "engine resources must extend EngineResources"
- )
- if resources is None or not isinstance(resources, resources_runtime_type):
- raise EngineRegistryError(
- "engine resources do not match their declared type"
- )
-
- self._registrations[engine_name] = _Registration(
- engine_type=engine_type,
- config_type=config_type,
- resources=resources,
- )
-
- def finalize(self) -> Self:
- """Freeze registrations, build the policy union, and return this registry."""
- if self.is_finalized:
- return self
- try:
- config_type = _build_privacy_guard_config_type(
- tuple(
- registration.config_type
- for registration in self._registrations.values()
- )
- )
- except ValueError:
- raise EngineRegistryError(
- "cannot finalize an empty engine registry"
- ) from None
- self._config_adapter = TypeAdapter(config_type)
- return self
-
- def validate_config(self, values: object) -> PrivacyGuardConfig[EngineConfig]:
- """Purely parse and validate an expanded Privacy Guard configuration."""
- if not isinstance(values, Mapping):
- raise PrivacyGuardError(ErrorCode.CONFIG_INVALID)
- try:
- config = self._require_config_adapter().validate_python(dict(values))
- except (TypeError, ValueError, ValidationError):
- raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) from None
- required_strategy = (
- EntityProcessingStrategy.REPLACE
- if config.on_detection.action is PolicyAction.REPLACE
- else EntityProcessingStrategy.DETECT
- )
- for stage in config.entity_processing.stages:
- registration = self._resolve_registration(stage.config)
- engine_type = registration.engine_type
- if not issubclass(engine_type, EntityProcessingEngine):
- raise EngineRegistryError("registered engine type is invalid")
- try:
- validate_run_config = getattr(engine_type, "validate_run_config")
- validate_run_config(
- stage.config,
- registration.resources,
- strategy=required_strategy,
- )
- except EngineConfigurationError:
- raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) from None
- return config
-
- def create_engine(
- self,
- config: EngineConfig,
- ) -> EntityProcessingEngine[EngineConfig, EngineResources | None]:
- """Construct an initialized engine from its exact validated config."""
- registration = self._resolve_registration(config)
- if type(config) is not registration.config_type:
- raise EngineRegistryError("engine config concrete type is invalid")
- return registration.engine_type(config, registration.resources)
-
- def configuration_json_schema(self) -> dict[str, object]:
- """Return the finalized complete policy JSON Schema."""
- return self._require_config_adapter().json_schema()
-
- def describe_engines(self) -> tuple[EngineDescription, ...]:
- """Return safe engine metadata without constructing runtime engines."""
- return tuple(
- EngineDescription(
- engine_name=engine,
- description=_engine_description(registration.engine_type),
- supported_strategies=_supported_strategies(registration.engine_type),
- )
- for engine, registration in self._registrations.items()
- )
-
- def _resolve_registration(
- self,
- config: EngineConfig,
- ) -> _Registration:
- if not self.is_finalized:
- raise EngineRegistryError("engine registry is not finalized")
- try:
- engine_name = getattr(config, "engine")
- if not isinstance(engine_name, str):
- raise AttributeError
- registration = self._registrations[engine_name]
- except (AttributeError, KeyError):
- raise EngineRegistryError("engine config is not registered") from None
- return registration
-
- def _require_config_adapter(
- self,
- ) -> TypeAdapter[PrivacyGuardConfig[EngineConfig]]:
- if self._config_adapter is None:
- raise EngineRegistryError("engine registry is not finalized")
- return self._config_adapter
-
-
-def create_builtin_registry() -> EngineRegistry:
- """Build the finalized registry shipped by the base package."""
- return EngineRegistry(include_builtin_engines=True).finalize()
-
-
-@dataclass(frozen=True)
-class _Registration:
- engine_type: type[object]
- config_type: type[EngineConfig]
- resources: EngineResources | None
-
-
-def _build_privacy_guard_config_type(
- config_types: Sequence[type[EngineConfig]],
-) -> type[PrivacyGuardConfig[EngineConfig]]:
- if not config_types:
- raise ValueError("at least one engine config type must be registered")
- registered_union = reduce(or_, config_types)
- registered_config = Annotated[
- registered_union, # ty: ignore[invalid-type-form]
- Field(discriminator="engine"),
- ]
- config_type = PrivacyGuardConfig.__class_getitem__(
- registered_config # ty: ignore[invalid-argument-type]
- )
- if not isinstance(config_type, type) or not issubclass(
- config_type, PrivacyGuardConfig
- ):
- raise TypeError("Pydantic did not construct a policy config type")
- return config_type # ty: ignore[invalid-return-type]
-
-
-def _supported_strategies(
- engine_type: type[object],
-) -> frozenset[EntityProcessingStrategy]:
- supported_strategies = getattr(engine_type, "supported_strategies", None)
- if (
- not isinstance(supported_strategies, frozenset)
- or not supported_strategies
- or any(
- not isinstance(strategy, EntityProcessingStrategy)
- for strategy in supported_strategies
- )
- ):
- raise EngineRegistryError("engine supported strategies are invalid")
- return supported_strategies
-
-
-def _engine_discriminator(
- config_type: type[EngineConfig],
-) -> str:
- field = config_type.model_fields.get("engine")
- if field is None:
- raise EngineRegistryError("engine config lacks an engine discriminator")
- if get_origin(field.annotation) is not Literal:
- raise EngineRegistryError("engine discriminator must be one string Literal")
- values = get_args(field.annotation)
- if len(values) != 1 or not isinstance(values[0], str):
- raise EngineRegistryError("engine discriminator must be one string Literal")
- engine = values[0]
- if _ENGINE_NAME.fullmatch(engine) is None or len(engine.encode("ascii")) > 128:
- raise EngineRegistryError("engine discriminator is invalid")
- if field.default is not PydanticUndefined and field.default != engine:
- raise EngineRegistryError("engine discriminator default is inconsistent")
- return engine
-
-
-def _engine_description(
- engine_type: type[object],
-) -> str:
- description = inspect.getdoc(engine_type) or ""
- first_line = description.splitlines()[0] if description else ""
- if len(first_line.encode("utf-8")) > 1024:
- return ""
- return first_line
-
-
-_ENGINE_NAME = re.compile(r"[a-z][a-z0-9-]{0,127}\Z")
-
-
-__all__ = [
- "EngineDescription",
- "EngineRegistry",
- "create_builtin_registry",
-]
diff --git a/projects/privacy-guard/src/privacy_guard/request_processor.py b/projects/privacy-guard/src/privacy_guard/request_processor.py
deleted file mode 100644
index 459f6fb8..00000000
--- a/projects/privacy-guard/src/privacy_guard/request_processor.py
+++ /dev/null
@@ -1,212 +0,0 @@
-"""Sequential entity-processing orchestration for one text input."""
-
-from __future__ import annotations
-
-from collections.abc import Sequence
-from enum import StrEnum
-
-from pydantic import Field
-
-from privacy_guard.base import StrictDomainModel
-from privacy_guard.config import PolicyAction, PrivacyGuardConfig
-from privacy_guard.constants import (
- BLOCK_REASON_CODE,
- DEFAULT_TIMEOUT_SECONDS,
- LIMIT_REASON_CODE,
- MAX_BODY_BYTES,
- MAX_DETECTIONS_PER_REQUEST,
-)
-from privacy_guard.engines import (
- ConfidenceLevel,
- EngineConfig,
- EngineResources,
- EntityProcessingEngine,
- EntityProcessingStrategy,
- TextProcessingResult,
-)
-from privacy_guard.errors import (
- EngineConfigurationError,
- EngineContractError,
- EngineLimitExceededError,
- EntityProcessingError,
- ErrorCode,
- PrivacyGuardError,
- TimeoutExpiredError,
-)
-from privacy_guard.logging import get_logger
-from privacy_guard.string_validators import validate_scalar_string
-from privacy_guard.timeout import Timeout, validate_timeout_seconds
-
-
-class RequestDecision(StrEnum):
- """Whether OpenShell should continue or stop the request."""
-
- ALLOW = "allow"
- DENY = "deny"
-
-
-class EntityDetectionSummary(StrictDomainModel):
- """One bounded aggregate suitable for user-facing audit output."""
-
- entity: str
- source_stage: str
- confidence: ConfidenceLevel | None = None
- count: int = Field(ge=1)
-
-
-class RequestProcessingResult(StrictDomainModel):
- """The processor's decision, summaries, and optional replacement text."""
-
- decision: RequestDecision
- replacement_text: str | None = Field(default=None, repr=False)
- detection_summaries: tuple[EntityDetectionSummary, ...] = ()
- reason_code: str | None = None
-
-
-class RequestProcessor:
- """Run configured entity-processing stages once, in policy order."""
-
- def __init__(
- self,
- config: PrivacyGuardConfig[EngineConfig],
- configured_engines: Sequence[
- tuple[
- str,
- EntityProcessingEngine[EngineConfig, EngineResources | None],
- ]
- ],
- *,
- timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
- log_request_content: bool = False,
- ) -> None:
- engines = tuple(configured_engines)
- if len(engines) != len(config.entity_processing.stages):
- raise ValueError("configured engines do not match the policy")
- if not engines:
- raise ValueError("at least one configured engine is required")
- sources = tuple(source for source, _ in engines)
- if any(not source for source in sources) or len(sources) != len(set(sources)):
- raise ValueError("engine sources must be non-empty and unique")
- self._config = config
- self._engines = engines
- self._timeout_seconds = validate_timeout_seconds(timeout_seconds)
- self._log_request_content = log_request_content
-
- def process(self, text: str) -> RequestProcessingResult:
- """Process one complete request text and apply the user-facing action."""
- try:
- input_text = validate_scalar_string(text)
- except ValueError:
- raise PrivacyGuardError(ErrorCode.BODY_ENCODING_INVALID) from None
- if len(input_text.encode("utf-8")) > MAX_BODY_BYTES:
- raise PrivacyGuardError(ErrorCode.REQUEST_BODY_TOO_LARGE)
- if self._log_request_content:
- _LOGGER.debug("privacy_guard_text_input text=%r", input_text)
-
- action = self._config.on_detection.action
- strategy = (
- EntityProcessingStrategy.REPLACE
- if action is PolicyAction.REPLACE
- else EntityProcessingStrategy.DETECT
- )
- timeout = Timeout.from_seconds(self._timeout_seconds)
- current_text = input_text
- stage_results: list[tuple[str, TextProcessingResult]] = []
- try:
- for source, engine in self._engines:
- _LOGGER.debug(
- "privacy_guard_stage_run source=%s strategy=%s",
- source,
- strategy.value,
- )
- result = engine.run(
- current_text,
- strategy=strategy,
- timeout=timeout,
- )
- if len(result.text.encode("utf-8")) > MAX_BODY_BYTES:
- raise EngineLimitExceededError(
- "intermediate text exceeds the limit"
- )
- if (
- sum(len(item.detections) for _, item in stage_results)
- + len(result.detections)
- > MAX_DETECTIONS_PER_REQUEST
- ):
- raise EngineLimitExceededError(
- "request detections exceed the limit"
- )
- stage_results.append((source, result))
- current_text = result.text
- timeout.raise_if_expired()
- except TimeoutExpiredError:
- _LOGGER.info("privacy_guard_processing_limit kind=timeout")
- return RequestProcessingResult(
- decision=RequestDecision.DENY,
- reason_code=LIMIT_REASON_CODE,
- )
- except EngineLimitExceededError:
- _LOGGER.info("privacy_guard_processing_limit kind=resource")
- return RequestProcessingResult(
- decision=RequestDecision.DENY,
- reason_code=LIMIT_REASON_CODE,
- )
- except EngineConfigurationError:
- raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) from None
- except EngineContractError:
- raise PrivacyGuardError(ErrorCode.ENGINE_OUTPUT_INVALID) from None
- except EntityProcessingError:
- raise PrivacyGuardError(ErrorCode.ENGINE_EXECUTION_FAILED) from None
- except PrivacyGuardError:
- raise
- except Exception:
- raise PrivacyGuardError(ErrorCode.ENGINE_EXECUTION_FAILED) from None
-
- detections = _aggregate_detections(stage_results)
- if action is PolicyAction.BLOCK and detections:
- return RequestProcessingResult(
- decision=RequestDecision.DENY,
- detection_summaries=detections,
- reason_code=BLOCK_REASON_CODE,
- )
- replacement_text = current_text if action is PolicyAction.REPLACE else None
- if self._log_request_content:
- _LOGGER.debug("privacy_guard_text_output text=%r", current_text)
- return RequestProcessingResult(
- decision=RequestDecision.ALLOW,
- replacement_text=replacement_text,
- detection_summaries=detections,
- )
-
-
-def _aggregate_detections(
- stage_results: Sequence[tuple[str, TextProcessingResult]],
-) -> tuple[EntityDetectionSummary, ...]:
- groups: dict[
- tuple[str, str, ConfidenceLevel | None],
- int,
- ] = {}
- for source, result in stage_results:
- for detection in result.detections:
- key = (source, detection.entity, detection.confidence)
- groups[key] = groups.get(key, 0) + 1
- return tuple(
- EntityDetectionSummary(
- source_stage=source,
- entity=entity,
- confidence=confidence,
- count=count,
- )
- for (source, entity, confidence), count in groups.items()
- )
-
-
-_LOGGER = get_logger(__name__)
-
-
-__all__ = [
- "EntityDetectionSummary",
- "RequestDecision",
- "RequestProcessingResult",
- "RequestProcessor",
-]
diff --git a/projects/privacy-guard/src/privacy_guard/service/__init__.py b/projects/privacy-guard/src/privacy_guard/service/__init__.py
deleted file mode 100644
index d4923885..00000000
--- a/projects/privacy-guard/src/privacy_guard/service/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""gRPC transport and servicer for the Privacy Guard middleware."""
-
-from privacy_guard.service.server import PrivacyGuardServer
-from privacy_guard.service.servicer import PrivacyGuardMiddleware
-
-__all__ = ["PrivacyGuardMiddleware", "PrivacyGuardServer"]
diff --git a/projects/privacy-guard/src/privacy_guard/service/server.py b/projects/privacy-guard/src/privacy_guard/service/server.py
deleted file mode 100644
index 4465c046..00000000
--- a/projects/privacy-guard/src/privacy_guard/service/server.py
+++ /dev/null
@@ -1,124 +0,0 @@
-"""Programmatic Privacy Guard gRPC server lifecycle."""
-
-from __future__ import annotations
-
-import asyncio
-
-import grpc
-
-from privacy_guard.bindings import supervisor_middleware_pb2_grpc as pb2_grpc
-from privacy_guard.constants import (
- DEFAULT_TIMEOUT_SECONDS,
- MAX_CONCURRENT_RPCS,
- MAX_RECEIVE_MESSAGE_BYTES,
-)
-from privacy_guard.engines.registry import EngineRegistry
-from privacy_guard.errors import ErrorCode, PrivacyGuardError
-from privacy_guard.logging import get_logger
-from privacy_guard.service.servicer import PrivacyGuardMiddleware
-
-DEFAULT_LISTEN_ADDRESS = "127.0.0.1:50051"
-
-
-class PrivacyGuardServer:
- """One-shot programmatic server for a finalized engine registry."""
-
- def __init__(
- self,
- registry: EngineRegistry,
- *,
- timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
- log_request_content: bool = False,
- ) -> None:
- self._middleware = PrivacyGuardMiddleware(
- registry,
- timeout_seconds=timeout_seconds,
- log_request_content=log_request_content,
- )
-
- def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None:
- """Serve synchronously until termination."""
- try:
- asyncio.run(self.serve_async(listen))
- except KeyboardInterrupt:
- return
-
- async def serve_async(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None:
- """Serve asynchronously until termination, then close owned resources."""
- server = _create_grpc_server(self._middleware)
- try:
- try:
- requested_port = _validated_listen_port(listen)
- bound_port = server.add_insecure_port(listen)
- if bound_port != requested_port:
- raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED)
- _LOGGER.info("privacy_guard_server_bound listen=%r", listen)
- await server.start()
- except RuntimeError:
- raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED) from None
- await server.wait_for_termination()
- finally:
- try:
- await _stop_grpc_server(server)
- finally:
- await self._middleware.close()
-
-
-_LOGGER = get_logger(__name__)
-
-
-def _create_grpc_server(
- middleware: PrivacyGuardMiddleware,
-) -> grpc.aio.Server:
- server = grpc.aio.server(
- maximum_concurrent_rpcs=MAX_CONCURRENT_RPCS,
- options=(("grpc.max_receive_message_length", MAX_RECEIVE_MESSAGE_BYTES),),
- )
- pb2_grpc.add_SupervisorMiddlewareServicer_to_server(middleware, server)
- return server
-
-
-async def _stop_grpc_server(server: grpc.aio.Server) -> None:
- shutdown = asyncio.create_task(server.stop(grace=0))
- try:
- await asyncio.shield(shutdown)
- except asyncio.CancelledError:
- if not shutdown.done():
- await shutdown
- raise
-
-
-def _validated_listen_port(listen: str) -> int:
- if not isinstance(listen, str):
- raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED)
- if listen.startswith("["):
- closing_bracket = listen.rfind("]")
- if (
- closing_bracket < 2
- or listen[closing_bracket + 1 : closing_bracket + 2] != ":"
- ):
- raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED)
- host = listen[1:closing_bracket]
- port_text = listen[closing_bracket + 2 :]
- else:
- host, separator, port_text = listen.rpartition(":")
- if not separator or not host or ":" in host:
- raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED)
- if (
- not host
- or not port_text
- or len(port_text) > 5
- or not port_text.isascii()
- or not port_text.isdecimal()
- ):
- raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED)
- port = int(port_text)
- if not 1 <= port <= 65_535:
- raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED)
- return port
-
-
-__all__ = [
- "DEFAULT_LISTEN_ADDRESS",
- "PrivacyGuardServer",
-]
diff --git a/projects/privacy-guard/src/privacy_guard/service/servicer.py b/projects/privacy-guard/src/privacy_guard/service/servicer.py
deleted file mode 100644
index 68b30585..00000000
--- a/projects/privacy-guard/src/privacy_guard/service/servicer.py
+++ /dev/null
@@ -1,457 +0,0 @@
-"""gRPC boundary for active entity-processing policy evaluation."""
-
-from __future__ import annotations
-
-import asyncio
-import json
-import math
-import time
-from collections.abc import Callable, Iterable
-from concurrent.futures import Future, ThreadPoolExecutor
-from threading import Lock
-from typing import Never, Protocol, TypedDict, TypeVar
-
-import grpc
-from google.protobuf import json_format
-from google.protobuf.message import Message
-
-from privacy_guard.bindings import supervisor_middleware_pb2 as pb2
-from privacy_guard.bindings import supervisor_middleware_pb2_grpc as pb2_grpc
-from privacy_guard.config import PrivacyGuardConfig
-from privacy_guard.constants import (
- BLOCK_REASON,
- BLOCK_REASON_CODE,
- DEFAULT_TIMEOUT_SECONDS,
- LIMIT_REASON,
- LIMIT_REASON_CODE,
- MAX_BODY_BYTES,
- MAX_CONCURRENT_PROCESSING,
- MAX_PROTO_CONFIG_BYTES,
- MAX_PROTO_CONTEXT_BYTES,
- MAX_PROTO_FINDING_BYTES,
- MAX_PROTO_FINDING_GROUPS,
- MAX_PROTO_HEADERS,
- MAX_PROTO_HEADERS_BYTES,
- MAX_PROTO_TARGET_BYTES,
- REASON_CODE_PATTERN,
- SERVICE_NAME,
- SERVICE_VERSION,
-)
-from privacy_guard.engines import EngineConfig
-from privacy_guard.engines.registry import EngineRegistry
-from privacy_guard.errors import (
- EngineRegistryError,
- ErrorCode,
- ErrorKind,
- PrivacyGuardError,
-)
-from privacy_guard.logging import get_logger
-from privacy_guard.request_processor import (
- EntityDetectionSummary,
- RequestDecision,
- RequestProcessingResult,
- RequestProcessor,
-)
-from privacy_guard.string_validators import validate_bounded_metadata_string
-from privacy_guard.timeout import validate_timeout_seconds
-
-
-class PrivacyGuardMiddleware(pb2_grpc.SupervisorMiddlewareServicer):
- """Validate, prepare, resolve, and run Privacy Guard policies."""
-
- def __init__(
- self,
- registry: EngineRegistry,
- *,
- timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
- log_request_content: bool = False,
- ) -> None:
- if not registry.is_finalized:
- raise EngineRegistryError("middleware requires a finalized engine registry")
- self._registry = registry
- self._policy = _ActivePolicy(
- registry,
- timeout_seconds=validate_timeout_seconds(timeout_seconds),
- log_request_content=log_request_content,
- )
- self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING)
- self._processing_executor = ThreadPoolExecutor(
- max_workers=MAX_CONCURRENT_PROCESSING,
- thread_name_prefix="privacy-guard-processing",
- )
-
- async def close(self) -> None:
- """Wait for in-flight synchronous engines during shutdown."""
- self._processing_executor.shutdown(wait=True, cancel_futures=True)
- self._policy.clear()
-
- async def Describe(
- self,
- request: object,
- context: grpc.aio.ServicerContext[object, pb2.MiddlewareManifest],
- ) -> pb2.MiddlewareManifest:
- """Advertise the binding and its finalized policy schema."""
- return pb2.MiddlewareManifest(
- name=SERVICE_NAME,
- service_version=SERVICE_VERSION,
- bindings=[
- pb2.MiddlewareBinding(
- operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST,
- phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS,
- max_body_bytes=MAX_BODY_BYTES,
- )
- ],
- )
-
- async def ValidateConfig(
- self,
- request: pb2.ValidateConfigRequest,
- context: grpc.aio.ServicerContext[
- pb2.ValidateConfigRequest,
- pb2.ValidateConfigResponse,
- ],
- ) -> pb2.ValidateConfigResponse:
- """Validate expanded configuration without preparing runtime state."""
- return await self._run_in_worker(lambda: self._validate_config(request))
-
- async def EvaluateHttpRequest(
- self,
- request: pb2.HttpRequestEvaluation,
- context: grpc.aio.ServicerContext[
- pb2.HttpRequestEvaluation,
- pb2.HttpRequestResult,
- ],
- ) -> pb2.HttpRequestResult:
- """Resolve the prepared config, decode one text, and process it."""
- return await self._evaluate_rpc(request, context)
-
- def _validate_config(
- self,
- request: pb2.ValidateConfigRequest,
- ) -> pb2.ValidateConfigResponse:
- try:
- if request.config.ByteSize() > MAX_PROTO_CONFIG_BYTES:
- raise PrivacyGuardError(ErrorCode.CONFIG_INVALID)
- self._registry.validate_config(_mapping_from_proto(request.config))
- except PrivacyGuardError as error:
- return pb2.ValidateConfigResponse(valid=False, reason=str(error))
- except Exception:
- error = PrivacyGuardError(ErrorCode.UNEXPECTED_SERVICE_FAILURE)
- return pb2.ValidateConfigResponse(valid=False, reason=str(error))
- return pb2.ValidateConfigResponse(valid=True)
-
- async def _evaluate_rpc(
- self,
- request: pb2.HttpRequestEvaluation,
- context: _AbortContext,
- ) -> pb2.HttpRequestResult:
- started = time.monotonic()
- request_id = _request_id_for_logging(request.context.request_id)
- failure: PrivacyGuardError | None = None
- action = "error"
- finding_count = 0
- try:
- response = await self._evaluate_http_request(request)
- action = "allow" if response.decision == pb2.DECISION_ALLOW else "deny"
- finding_count = sum(finding.count for finding in response.findings)
- return response
- except PrivacyGuardError as error:
- failure = error
- except Exception:
- failure = PrivacyGuardError(ErrorCode.UNEXPECTED_SERVICE_FAILURE)
- finally:
- log_extra = _evaluation_log_extra(
- request_id=request_id,
- started=started,
- action=action,
- finding_count=finding_count,
- failure=failure,
- )
- _LOGGER.info(
- "privacy_guard_evaluation request_id=%s duration_ms=%.3f "
- "action=%s finding_count=%d error_code=%s",
- _request_id_for_log_message(log_extra["request_id"]),
- log_extra["duration_ms"],
- log_extra["action"],
- log_extra["finding_count"],
- log_extra["error_code"] or "none",
- extra=log_extra,
- )
- status = (
- grpc.StatusCode.INVALID_ARGUMENT
- if failure.kind is ErrorKind.INVALID_INPUT
- else grpc.StatusCode.INTERNAL
- )
- await context.abort(status, str(failure))
-
- async def _evaluate_http_request(
- self,
- request: pb2.HttpRequestEvaluation,
- ) -> pb2.HttpRequestResult:
- if request.phase != pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS:
- raise PrivacyGuardError(ErrorCode.REQUEST_PHASE_INVALID)
- if len(request.body) > MAX_BODY_BYTES:
- raise PrivacyGuardError(ErrorCode.REQUEST_BODY_TOO_LARGE)
- _validate_evaluation_envelope(request)
- result = await self._run_in_worker(
- lambda: self._prepare_and_process(request.config, request.body)
- )
- return _result_to_proto(result)
-
- def _prepare_and_process(
- self,
- config: Message,
- body: bytes,
- ) -> RequestProcessingResult:
- values = _mapping_from_proto(config)
- processor = self._policy.processor_for(values)
- if not body:
- return RequestProcessingResult(decision=RequestDecision.ALLOW)
- try:
- text = body.decode("utf-8", errors="strict")
- except UnicodeDecodeError:
- raise PrivacyGuardError(ErrorCode.BODY_ENCODING_INVALID) from None
- return processor.process(text)
-
- async def _run_in_worker(
- self,
- operation: Callable[[], _WorkerResultT],
- ) -> _WorkerResultT:
- """Run one bounded synchronous operation without blocking the event loop."""
- await self._processing_slots.acquire()
- try:
- worker = self._processing_executor.submit(operation)
- future = asyncio.create_task(_await_worker(worker))
- except BaseException:
- self._processing_slots.release()
- raise
- future.add_done_callback(lambda _: self._processing_slots.release())
- return await asyncio.shield(future)
-
-
-class _ActivePolicy:
- """Own the process's active policy and its prepared processor."""
-
- def __init__(
- self,
- registry: EngineRegistry,
- *,
- timeout_seconds: float,
- log_request_content: bool,
- ) -> None:
- self._registry = registry
- self._timeout_seconds = timeout_seconds
- self._log_request_content = log_request_content
- self._config: PrivacyGuardConfig[EngineConfig] | None = None
- self._processor: RequestProcessor | None = None
- self._lock = Lock()
-
- def processor_for(self, values: object) -> RequestProcessor:
- """Return the processor for the requested policy, activating it if needed."""
- config = self._registry.validate_config(values)
- with self._lock:
- if config == self._config and self._processor is not None:
- return self._processor
- processor = self._build_processor(config)
- self._config = config
- self._processor = processor
- return processor
-
- def _build_processor(
- self,
- config: PrivacyGuardConfig[EngineConfig],
- ) -> RequestProcessor:
- stages = tuple(
- (
- stage.diagnostic_name(index),
- self._registry.create_engine(stage.config),
- )
- for index, stage in enumerate(
- config.entity_processing.stages,
- start=1,
- )
- )
- return RequestProcessor(
- config,
- stages,
- timeout_seconds=self._timeout_seconds,
- log_request_content=self._log_request_content,
- )
-
- def clear(self) -> None:
- """Release the active policy."""
- with self._lock:
- self._config = None
- self._processor = None
-
-
-_WorkerResultT = TypeVar("_WorkerResultT")
-
-
-async def _await_worker(worker: Future[_WorkerResultT]) -> _WorkerResultT:
- """Bridge a worker without relying on broken cross-thread loop wakeups."""
- while not worker.done():
- await asyncio.sleep(0.001)
- return worker.result()
-
-
-class _AbortContext(Protocol):
- async def abort(self, code: grpc.StatusCode, details: str) -> Never: ...
-
-
-class _EvaluationLogExtra(TypedDict):
- request_id: str
- duration_ms: float
- action: str
- finding_count: int
- error_code: str | None
-
-
-def _evaluation_log_extra(
- *,
- request_id: str,
- started: float,
- action: str,
- finding_count: int,
- failure: PrivacyGuardError | None,
-) -> _EvaluationLogExtra:
- return {
- "request_id": request_id,
- "duration_ms": round((time.monotonic() - started) * 1000, 3),
- "action": action,
- "finding_count": finding_count,
- "error_code": failure.code.value if failure is not None else None,
- }
-
-
-def _request_id_for_logging(request_id: object) -> str:
- try:
- return validate_bounded_metadata_string(request_id)
- except ValueError:
- return _INVALID_REQUEST_ID
-
-
-def _request_id_for_log_message(request_id: str) -> str:
- return json.dumps(request_id, ensure_ascii=False).replace(" ", r"\u0020")
-
-
-def _mapping_from_proto(config: Message) -> dict[str, object]:
- try:
- values: object = json_format.MessageToDict(config)
- except Exception:
- raise PrivacyGuardError(ErrorCode.CONFIG_INVALID) from None
- if not isinstance(values, dict) or any(not isinstance(key, str) for key in values):
- raise PrivacyGuardError(ErrorCode.CONFIG_INVALID)
- return {
- key: _normalize_proto_numbers(item)
- for key, item in values.items()
- if isinstance(key, str)
- }
-
-
-def _normalize_proto_numbers(value: object) -> object:
- if isinstance(value, float):
- if (
- math.isfinite(value)
- and value.is_integer()
- and -_MAX_PROTO_SAFE_INTEGER <= value <= _MAX_PROTO_SAFE_INTEGER
- ):
- return int(value)
- return value
- if isinstance(value, list):
- return [_normalize_proto_numbers(item) for item in value]
- if isinstance(value, dict):
- return {key: _normalize_proto_numbers(item) for key, item in value.items()}
- return value
-
-
-def _validate_evaluation_envelope(request: pb2.HttpRequestEvaluation) -> None:
- if request.config.ByteSize() > MAX_PROTO_CONFIG_BYTES:
- raise PrivacyGuardError(ErrorCode.CONFIG_INVALID)
- if (
- request.context.ByteSize() > MAX_PROTO_CONTEXT_BYTES
- or request.target.ByteSize() > MAX_PROTO_TARGET_BYTES
- or len(request.headers) > MAX_PROTO_HEADERS
- or _encoded_headers_size(request.headers) > MAX_PROTO_HEADERS_BYTES
- ):
- raise PrivacyGuardError(ErrorCode.REQUEST_ENVELOPE_INVALID)
-
-
-def _encoded_headers_size(headers: Iterable[Message]) -> int:
- total = 0
- for header in headers:
- size = header.ByteSize()
- total += 1 + _varint_size(size) + size
- return total
-
-
-def _varint_size(value: int) -> int:
- size = 1
- while value >= 0x80:
- value >>= 7
- size += 1
- return size
-
-
-def _result_to_proto(result: RequestProcessingResult) -> pb2.HttpRequestResult:
- findings: list[pb2.Finding] = []
- for detection in result.detection_summaries:
- finding = _detection_to_proto(detection)
- if finding.ByteSize() > MAX_PROTO_FINDING_BYTES:
- return _limit_deny()
- findings.append(finding)
- if len(findings) > MAX_PROTO_FINDING_GROUPS:
- return _limit_deny()
- if result.decision is RequestDecision.ALLOW:
- replacement = result.replacement_text
- replacement_body = (
- replacement.encode("utf-8") if replacement is not None else b""
- )
- if len(replacement_body) > MAX_BODY_BYTES:
- return _limit_deny()
- return pb2.HttpRequestResult(
- decision=pb2.DECISION_ALLOW,
- body=replacement_body,
- has_body=replacement is not None,
- findings=findings,
- )
- if result.decision is RequestDecision.DENY:
- reason_code = result.reason_code or BLOCK_REASON_CODE
- if REASON_CODE_PATTERN.fullmatch(reason_code) is None:
- return _limit_deny()
- return pb2.HttpRequestResult(
- decision=pb2.DECISION_DENY,
- reason=LIMIT_REASON if reason_code == LIMIT_REASON_CODE else BLOCK_REASON,
- reason_code=reason_code,
- findings=findings,
- )
- raise PrivacyGuardError(ErrorCode.UNEXPECTED_SERVICE_FAILURE)
-
-
-def _detection_to_proto(detection: EntityDetectionSummary) -> pb2.Finding:
- confidence = detection.confidence
- confidence_text = confidence.value if confidence is not None else ""
- result = pb2.Finding(
- type="detected_entity",
- label=f"{detection.entity} ({detection.source_stage})",
- confidence=confidence_text,
- count=detection.count,
- )
- return result
-
-
-def _limit_deny() -> pb2.HttpRequestResult:
- _LOGGER.info("privacy_guard_processing_limit kind=resource")
- return pb2.HttpRequestResult(
- decision=pb2.DECISION_DENY,
- reason=LIMIT_REASON,
- reason_code=LIMIT_REASON_CODE,
- )
-
-
-_LOGGER = get_logger(__name__)
-_INVALID_REQUEST_ID = "invalid"
-_MAX_PROTO_SAFE_INTEGER = (1 << 53) - 1
-
-
-__all__ = ["PrivacyGuardMiddleware"]
diff --git a/projects/privacy-guard/tests/__init__.py b/projects/privacy-guard/tests/__init__.py
deleted file mode 100644
index 2017165c..00000000
--- a/projects/privacy-guard/tests/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Privacy Guard test package."""
diff --git a/projects/privacy-guard/tests/engines/test_base.py b/projects/privacy-guard/tests/engines/test_base.py
deleted file mode 100644
index 2dcae3d4..00000000
--- a/projects/privacy-guard/tests/engines/test_base.py
+++ /dev/null
@@ -1,323 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Iterator
-from dataclasses import dataclass
-from typing import Literal
-
-import pytest
-from pydantic import ValidationError
-
-from privacy_guard.base import StrictDomainModel
-from privacy_guard.constants import MAX_DETECTIONS_PER_STAGE
-from privacy_guard.engines import (
- ConfidenceLevel,
- EngineConfig,
- EngineContractError,
- EngineLimitExceededError,
- EngineResources,
- EntityDetection,
- EntityProcessingEngine,
- EntityProcessingStrategy,
- TextProcessingResult,
-)
-from privacy_guard.timeout import Timeout
-
-
-class _Replacement(StrictDomainModel):
- strategy: Literal["token"] = "token"
-
-
-class _Config(EngineConfig):
- engine: Literal["test"] = "test"
- replacement: _Replacement | None = None
-
-
-@dataclass(frozen=True)
-class _Resources(EngineResources):
- prefix: str
-
-
-class _CustomEngine(EntityProcessingEngine[_Config, _Resources]):
- supported_strategies = frozenset(
- {
- EntityProcessingStrategy.DETECT,
- EntityProcessingStrategy.REPLACE,
- }
- )
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- detection = EntityDetection(
- entity="token",
- start=0,
- end=len(text),
- confidence=ConfidenceLevel.HIGH,
- metadata={"provider": "custom"},
- )
- output = (
- f"{self.resources.prefix}token"
- if strategy is EntityProcessingStrategy.REPLACE
- else text
- )
- return TextProcessingResult(text=output, detections=(detection,))
-
-
-def test_custom_engine_infers_types_and_needs_no_custom_init() -> None:
- config = _Config(replacement=_Replacement())
- resources = _Resources(prefix="[")
-
- engine = _CustomEngine(config, resources)
-
- assert _CustomEngine.get_config_type() is _Config
- assert _CustomEngine.get_resources_type() is _Resources
- assert engine.config is config
- assert engine.resources is resources
- assert (
- engine.run(
- "secret",
- strategy=EntityProcessingStrategy.DETECT,
- timeout=Timeout.from_seconds(1),
- ).text
- == "secret"
- )
- assert (
- engine.run(
- "secret",
- strategy=EntityProcessingStrategy.REPLACE,
- timeout=Timeout.from_seconds(1),
- ).text
- == "[token"
- )
-
-
-def test_detection_confidence_and_metadata_are_strict_bounded_values() -> None:
- categorical = EntityDetection.model_validate(
- {
- "entity": "email",
- "start": 0,
- "end": 1,
- "confidence": "high",
- "metadata": {"rule": "email.rules[0]"},
- }
- )
- assert categorical.confidence is ConfidenceLevel.HIGH
- assert type(categorical.metadata).__name__ == "mappingproxy"
- with pytest.raises(ValidationError):
- EntityDetection.model_validate(
- {
- "entity": "email",
- "start": 0,
- "end": 1,
- "confidence": 0.25,
- }
- )
-
-
-@pytest.mark.parametrize(
- "unsafe_value",
- [
- "line\nbreak",
- "ansi\x1b[31m",
- "nul\x00byte",
- "right-to-left\u202eoverride",
- ],
-)
-def test_detection_rejects_non_printable_identifiers_and_metadata(
- unsafe_value: str,
-) -> None:
- with pytest.raises(ValidationError):
- EntityDetection(
- entity=unsafe_value,
- start=0,
- end=1,
- )
- with pytest.raises(ValidationError):
- EntityDetection(
- entity="token",
- start=0,
- end=1,
- metadata={unsafe_value: "value"},
- )
- with pytest.raises(ValidationError):
- EntityDetection(
- entity="token",
- start=0,
- end=1,
- metadata={"key": unsafe_value},
- )
-
-
-def test_detection_accepts_printable_unicode_identifiers_and_metadata() -> None:
- detection = EntityDetection(
- entity="客户资料",
- start=0,
- end=1,
- metadata={"提供者": "自定义 🛡️"},
- )
-
- assert detection.entity == "客户资料"
- assert detection.metadata == {"提供者": "自定义 🛡️"}
-
-
-def test_processing_result_bounds_a_lazy_detection_stream() -> None:
- produced = 0
-
- def detections() -> Iterator[EntityDetection]:
- nonlocal produced
- for index in range(1_000):
- produced += 1
- yield EntityDetection(entity="token", start=index, end=index + 1)
-
- with pytest.raises(EngineLimitExceededError):
- TextProcessingResult.from_detections(
- text="x" * 1_000,
- detections=detections(),
- )
-
- assert produced == 257
-
-
-class _OversizedResultEngine(EntityProcessingEngine[_Config]):
- supported_strategies = frozenset({EntityProcessingStrategy.DETECT})
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- del strategy, timeout
- return TextProcessingResult(
- text=text,
- detections=tuple(
- EntityDetection(entity="token", start=0, end=1)
- for _ in range(MAX_DETECTIONS_PER_STAGE + 1)
- ),
- )
-
-
-def test_engine_boundary_bounds_results_built_without_lazy_helper() -> None:
- engine = _OversizedResultEngine(_Config(), None)
-
- with pytest.raises(EngineLimitExceededError):
- engine.run(
- "text",
- strategy=EntityProcessingStrategy.DETECT,
- timeout=Timeout.from_seconds(1),
- )
-
-
-class _DetectOnlyEngine(EntityProcessingEngine[_Config]):
- supported_strategies = frozenset({EntityProcessingStrategy.DETECT})
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- return TextProcessingResult(text=text, detections=())
-
-
-def test_detect_only_engine_rejects_replacement_before_running() -> None:
- engine = _DetectOnlyEngine(_Config(), None)
-
- assert _DetectOnlyEngine.get_resources_type() is None
- assert engine.resources is None
- with pytest.raises(EngineContractError):
- engine.run(
- "text",
- strategy=EntityProcessingStrategy.REPLACE,
- timeout=Timeout.from_seconds(1),
- )
-
-
-class _ReplaceOnlyEngine(EntityProcessingEngine[_Config]):
- supported_strategies = frozenset({EntityProcessingStrategy.REPLACE})
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- del strategy, timeout
- return TextProcessingResult(text=text, detections=())
-
-
-def test_replace_only_engine_rejects_detection_before_running() -> None:
- engine = _ReplaceOnlyEngine(_Config(replacement=_Replacement()), None)
-
- with pytest.raises(EngineContractError):
- engine.run(
- "text",
- strategy=EntityProcessingStrategy.DETECT,
- timeout=Timeout.from_seconds(1),
- )
-
-
-class _MutatingDetectEngine(EntityProcessingEngine[_Config]):
- supported_strategies = frozenset(
- {
- EntityProcessingStrategy.DETECT,
- EntityProcessingStrategy.REPLACE,
- }
- )
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- return TextProcessingResult(
- text="changed",
- detections=(EntityDetection(entity="token", start=0, end=len(text)),),
- )
-
-
-def test_detection_strategy_rejects_mutated_text() -> None:
- engine = _MutatingDetectEngine(_Config(), None)
-
- with pytest.raises(EngineContractError):
- engine.run(
- "text",
- strategy=EntityProcessingStrategy.DETECT,
- timeout=Timeout.from_seconds(1),
- )
-
-
-class _InvalidSpanEngine(EntityProcessingEngine[_Config]):
- supported_strategies = frozenset({EntityProcessingStrategy.DETECT})
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- return TextProcessingResult(
- text=text,
- detections=(EntityDetection(entity="token", start=0, end=len(text) + 1),),
- )
-
-
-def test_engine_boundary_rejects_spans_outside_stage_input() -> None:
- engine = _InvalidSpanEngine(_Config(), None)
-
- with pytest.raises(EngineContractError):
- engine.run(
- "text",
- strategy=EntityProcessingStrategy.DETECT,
- timeout=Timeout.from_seconds(1),
- )
diff --git a/projects/privacy-guard/tests/engines/test_regex.py b/projects/privacy-guard/tests/engines/test_regex.py
deleted file mode 100644
index 54ddd3f6..00000000
--- a/projects/privacy-guard/tests/engines/test_regex.py
+++ /dev/null
@@ -1,476 +0,0 @@
-from __future__ import annotations
-
-import logging
-from concurrent.futures import ThreadPoolExecutor
-from threading import Barrier
-
-import pytest
-from pydantic import ValidationError
-
-import privacy_guard.engines.regex as regex_module
-from privacy_guard.engines import (
- EngineConfigurationError,
- EngineLimitExceededError,
- EntityProcessingStrategy,
- RegexEngine,
- RegexEngineConfig,
- RegexPatternCatalog,
-)
-from privacy_guard.errors import TimeoutExpiredError
-from privacy_guard.timeout import Timeout
-
-
-def _config(
- rules: list[dict[str, object]],
- *,
- replacement: dict[str, object] | None = None,
-) -> RegexEngineConfig:
- values: dict[str, object] = {
- "engine": "regex",
- "pattern_catalog": {
- "entities": [
- {
- "name": "token",
- "rules": rules,
- }
- ]
- },
- }
- if replacement is not None:
- values["replacement"] = replacement
- return RegexEngineConfig.model_validate(values)
-
-
-def _run(
- config: RegexEngineConfig,
- text: str,
- strategy: EntityProcessingStrategy = EntityProcessingStrategy.DETECT,
-) -> tuple[str, list[tuple[str, int, int, str]]]:
- result = RegexEngine(config, None).run(
- text,
- strategy=strategy,
- timeout=Timeout.from_seconds(1),
- )
- return result.text, [
- (
- detection.entity,
- detection.start,
- detection.end,
- detection.metadata["rule"],
- )
- for detection in result.detections
- ]
-
-
-def _catalog(pattern: str) -> RegexPatternCatalog:
- return RegexPatternCatalog.model_validate(
- {
- "entities": [
- {
- "name": "token",
- "rules": [
- {
- "pattern": pattern,
- "confidence": "high",
- }
- ],
- }
- ]
- }
- )
-
-
-def test_detects_overlaps_and_orders_matches_deterministically() -> None:
- config = _config(
- [
- {"name": "pair", "pattern": "aa", "confidence": "high"},
- {"name": "suffix", "pattern": "a$", "confidence": "medium"},
- ]
- )
-
- output, detections = _run(config, "aaa")
-
- assert output == "aaa"
- assert detections == [
- ("token", 0, 2, "pair"),
- ("token", 1, 3, "pair"),
- ("token", 2, 3, "suffix"),
- ]
-
-
-def test_optional_names_derive_identity_without_affecting_internal_marker() -> None:
- config = _config(
- [
- {"name": "same-name", "pattern": "x", "confidence": "high"},
- {"name": "same_name", "pattern": "y", "confidence": "high"},
- {"pattern": "z", "confidence": "high"},
- ]
- )
-
- _, detections = _run(config, "xyz")
-
- assert [item[3] for item in detections] == [
- "same-name",
- "same_name",
- "token.rules[2]",
- ]
-
-
-def test_numeric_backreferences_keep_original_group_numbers() -> None:
- config = _config([{"pattern": r"(a)\1", "confidence": "high"}])
-
- _, detections = _run(config, "aa")
-
- assert [(item[1], item[2]) for item in detections] == [(0, 2)]
-
-
-def test_explicit_flags_are_supported() -> None:
- config = _config(
- [
- {
- "pattern": "^x.$",
- "confidence": "high",
- "ignore_case": True,
- "multiline": True,
- "dot_all": True,
- "ascii": True,
- }
- ]
- )
-
- _, detections = _run(config, "X\n")
-
- assert [(item[1], item[2]) for item in detections] == [(0, 2)]
-
-
-@pytest.mark.parametrize(
- "pattern",
- [
- "",
- "x*",
- "(?Px)",
- "(?i:x)",
- ],
-)
-def test_invalid_patterns_are_rejected_content_safely(pattern: str) -> None:
- with pytest.raises(ValidationError) as exception_info:
- _config([{"pattern": pattern, "confidence": "high"}])
-
- if pattern:
- assert pattern not in str(exception_info.value)
-
-
-@pytest.mark.parametrize(
- ("pattern", "text"),
- [
- ("x|(?=SECRET-zero-width-493)", "SECRET-zero-width-493"),
- ("(?=secret)", "secret"),
- ("(?<=prefix)", "prefix"),
- (r"\b", "secret"),
- ("x|(?:y|(?=secret))", "secret"),
- ],
-)
-def test_contextual_zero_width_match_is_invalid_configuration_at_runtime(
- pattern: str,
- text: str,
-) -> None:
- config = _config([{"pattern": pattern, "confidence": "high"}])
- engine = RegexEngine(config, None)
-
- with pytest.raises(
- EngineConfigurationError,
- match="regex engine configuration is invalid",
- ) as exception_info:
- engine.run(
- text,
- strategy=EntityProcessingStrategy.DETECT,
- timeout=Timeout.from_seconds(1),
- )
-
- assert pattern not in str(exception_info.value)
-
-
-@pytest.mark.parametrize(
- ("pattern", "text", "expected_span"),
- [
- ("(?<=prefix)secret(?=suffix)", "prefixsecretsuffix", (6, 12)),
- (r"\bsecret\b", "a secret value", (2, 8)),
- (
- r"(? None:
- config = _config([{"pattern": pattern, "confidence": "high"}])
-
- _, detections = _run(config, text)
-
- assert [(item[1], item[2]) for item in detections] == [expected_span]
-
-
-def test_duplicate_supplied_names_are_rejected_but_unnamed_rules_are_not() -> None:
- with pytest.raises(ValidationError):
- _config(
- [
- {"name": "duplicate", "pattern": "x", "confidence": "high"},
- {"name": "duplicate", "pattern": "y", "confidence": "high"},
- ]
- )
-
- config = _config(
- [
- {"pattern": "x", "confidence": "high"},
- {"pattern": "y", "confidence": "high"},
- ]
- )
- assert len(config.pattern_catalog.entities[0].rules) == 2
-
-
-def test_replacement_selects_ranked_non_overlapping_winners() -> None:
- config = _config(
- [
- {"name": "long-low", "pattern": "abc", "confidence": "low"},
- {"name": "short-high", "pattern": "bc", "confidence": "high"},
- ],
- replacement={"strategy": "template", "template": "<{entity}>"},
- )
-
- output, detections = _run(
- config,
- "abc",
- EntityProcessingStrategy.REPLACE,
- )
-
- assert output == "a"
- assert len(detections) == 2
-
-
-def test_replacement_requires_an_engine_specific_recipe() -> None:
- config = _config([{"pattern": "x", "confidence": "high"}])
-
- with pytest.raises(EngineConfigurationError):
- _run(config, "x", EntityProcessingStrategy.REPLACE)
-
-
-@pytest.mark.parametrize(
- "replacement",
- [
- {"strategy": "template", "template": "{unknown}"},
- {"strategy": "template", "template": "{entity.attr}"},
- {"strategy": "template", "template": "{entity!r}"},
- {"strategy": "template", "template": "{entity:>10}"},
- {"strategy": "template", "template": "{"},
- ],
-)
-def test_template_language_allows_only_literal_text_and_entity(
- replacement: dict[str, object],
-) -> None:
- with pytest.raises(ValidationError):
- _config(
- [{"pattern": "x", "confidence": "high"}],
- replacement=replacement,
- )
-
-
-def test_replacement_size_is_projected_before_rendering(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- monkeypatch.setattr(regex_module, "MAX_BODY_BYTES", 4)
- config = _config(
- [{"pattern": "x", "confidence": "high"}],
- replacement={"strategy": "template", "template": "[{entity}]"},
- )
-
- with pytest.raises(EngineLimitExceededError):
- _run(config, "x", EntityProcessingStrategy.REPLACE)
-
-
-def test_pattern_search_has_an_enforceable_timeout() -> None:
- config = _config([{"pattern": "(a+)+$", "confidence": "high"}])
- engine = RegexEngine(config, None)
-
- with pytest.raises(TimeoutExpiredError):
- engine.run(
- "a" * 100_000 + "!",
- strategy=EntityProcessingStrategy.DETECT,
- timeout=Timeout.from_seconds(0.001),
- )
-
-
-def test_patterns_compile_during_validation_and_preparation_not_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)
-
- monkeypatch.setattr(regex_module.regex, "compile", recording_compile)
- config = _config([{"pattern": "x", "confidence": "high"}])
- engine = RegexEngine(config, None)
- prepared_count = compile_count
-
- engine.run(
- "x",
- strategy=EntityProcessingStrategy.DETECT,
- timeout=Timeout.from_seconds(1),
- )
-
- assert prepared_count > 0
- assert compile_count == prepared_count
-
-
-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="privacy_guard.engines.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 (
- "privacy_guard_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="privacy_guard.engines.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("privacy_guard_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,
- ) -> regex_module._CompiledRule:
- workers_ready.wait(timeout=5)
- return original_compile_rule(
- entity,
- rule,
- catalog_index,
- entity_rule_index,
- )
-
- 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_engine_is_safe_for_concurrent_runs() -> None:
- engine = RegexEngine(
- _config([{"pattern": "x", "confidence": "high"}]),
- None,
- )
-
- def run(text: str) -> int:
- return len(
- engine.run(
- text,
- strategy=EntityProcessingStrategy.DETECT,
- timeout=Timeout.from_seconds(1),
- ).detections
- )
-
- with ThreadPoolExecutor(max_workers=4) as executor:
- counts = tuple(executor.map(run, ("x",) * 16))
-
- assert counts == (1,) * 16
diff --git a/projects/privacy-guard/tests/engines/test_registry.py b/projects/privacy-guard/tests/engines/test_registry.py
deleted file mode 100644
index fc600471..00000000
--- a/projects/privacy-guard/tests/engines/test_registry.py
+++ /dev/null
@@ -1,398 +0,0 @@
-"""Tests for entity-processing engine registration and schema finalization."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Literal
-
-import pytest
-from pydantic import field_validator
-
-from privacy_guard.base import StrictDomainModel
-from privacy_guard.engines import (
- EngineConfig,
- EngineConfigurationError,
- EngineResources,
- EntityProcessingEngine,
- EntityProcessingStrategy,
- RegexEngine,
- TextProcessingResult,
-)
-from privacy_guard.engines.registry import EngineRegistry, create_builtin_registry
-from privacy_guard.errors import EngineRegistryError, PrivacyGuardError
-from privacy_guard.timeout import Timeout
-
-
-class AcmeReplacement(StrictDomainModel):
- strategy: Literal["token"] = "token"
-
-
-class AcmeConfig(EngineConfig):
- engine: Literal["acme-pii"] = "acme-pii"
- entities: tuple[str, ...]
- replacement: AcmeReplacement | None = None
-
- @field_validator("entities", mode="before")
- @classmethod
- def _entities_are_a_tuple(cls, value: object) -> object:
- if not isinstance(value, list | tuple):
- raise ValueError("entities must be a list")
- return tuple(value)
-
-
-@dataclass(frozen=True)
-class AcmeResources(EngineResources):
- prefix: str
-
-
-class AcmeEngine(EntityProcessingEngine[AcmeConfig, AcmeResources]):
- supported_strategies = frozenset(
- {
- EntityProcessingStrategy.DETECT,
- EntityProcessingStrategy.REPLACE,
- }
- )
-
- @classmethod
- def _validate_run_config(
- cls,
- config: AcmeConfig,
- resources: AcmeResources,
- *,
- strategy: EntityProcessingStrategy,
- ) -> None:
- del cls, resources
- if strategy is EntityProcessingStrategy.REPLACE and config.replacement is None:
- raise EngineConfigurationError("acme replacement configuration is required")
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- del strategy, timeout
- return TextProcessingResult(text=text, detections=())
-
-
-class DetectConfig(EngineConfig):
- engine: Literal["detect-only"] = "detect-only"
-
-
-class DetectEngine(EntityProcessingEngine[DetectConfig]):
- supported_strategies = frozenset({EntityProcessingStrategy.DETECT})
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- del strategy, timeout
- return TextProcessingResult(text=text, detections=())
-
-
-def _acme_values(*, action: str = "detect") -> dict[str, object]:
- return {
- "entity_processing": {
- "stages": [
- {
- "config": {
- "engine": "acme-pii",
- "entities": ["account"],
- "replacement": {"strategy": "token"},
- }
- }
- ]
- },
- "on_detection": {"action": action},
- }
-
-
-def test_builtin_registry_contains_the_builtin_regex_engine() -> None:
- registry = create_builtin_registry()
-
- assert registry.is_finalized is True
- descriptions = registry.describe_engines()
- assert tuple(item.engine_name for item in descriptions) == ("regex",)
- description = descriptions[0]
- assert description.engine_name == "regex"
- assert description.supported_strategies == frozenset(
- {
- EntityProcessingStrategy.DETECT,
- EntityProcessingStrategy.REPLACE,
- }
- )
-
-
-def test_registry_can_include_builtin_engines_before_custom_registration() -> None:
- registry = EngineRegistry(include_builtin_engines=True)
- registry.register(AcmeEngine, resources=AcmeResources(prefix="token"))
- registry.finalize()
-
- assert tuple(item.engine_name for item in registry.describe_engines()) == (
- "regex",
- "acme-pii",
- )
-
-
-def test_custom_engine_config_joins_the_exact_discriminated_union() -> None:
- resources = AcmeResources(prefix="token")
- registry = EngineRegistry(include_builtin_engines=True)
- registry.register(AcmeEngine, resources=resources)
- registry.finalize()
-
- config = registry.validate_config(_acme_values(action="replace"))
- engine = registry.create_engine(config.entity_processing.stages[0].config)
-
- assert type(config.entity_processing.stages[0].config) is AcmeConfig
- assert type(engine) is AcmeEngine
- assert engine.config is config.entity_processing.stages[0].config
- assert engine.resources is resources
- assert tuple(item.engine_name for item in registry.describe_engines()) == (
- "regex",
- "acme-pii",
- )
-
-
-def test_detection_only_engine_is_rejected_for_replace_action() -> None:
- registry = EngineRegistry()
- registry.register(DetectEngine)
- registry.finalize()
- values = {
- "entity_processing": {"stages": [{"config": {"engine": "detect-only"}}]},
- "on_detection": {"action": "replace"},
- }
-
- with pytest.raises(PrivacyGuardError):
- registry.validate_config(values)
-
-
-def test_engine_owns_strategy_specific_configuration_requirements() -> None:
- registry = EngineRegistry()
- registry.register(AcmeEngine, resources=AcmeResources(prefix="token"))
- registry.finalize()
- values = {
- "entity_processing": {
- "stages": [
- {
- "config": {
- "engine": "acme-pii",
- "entities": ["account"],
- }
- }
- ]
- },
- "on_detection": {"action": "replace"},
- }
-
- with pytest.raises(PrivacyGuardError):
- registry.validate_config(values)
-
-
-class ReplaceOnlyConfig(EngineConfig):
- engine: Literal["replace-only"] = "replace-only"
-
-
-class ReplaceOnlyEngine(EntityProcessingEngine[ReplaceOnlyConfig]):
- supported_strategies = frozenset({EntityProcessingStrategy.REPLACE})
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- del strategy, timeout
- return TextProcessingResult(text=text, detections=())
-
-
-def test_replacement_only_engine_is_rejected_for_detect_action() -> None:
- registry = EngineRegistry()
- registry.register(ReplaceOnlyEngine)
- registry.finalize()
- values = {
- "entity_processing": {
- "stages": [
- {
- "config": {
- "engine": "replace-only",
- }
- }
- ]
- },
- "on_detection": {"action": "detect"},
- }
-
- with pytest.raises(PrivacyGuardError):
- registry.validate_config(values)
-
- values["on_detection"] = {"action": "replace"}
- config = registry.validate_config(values)
-
- config_type = type(config.entity_processing.stages[0].config)
- assert "replacement" not in config_type.model_fields
-
-
-def test_registry_is_frozen_after_finalize_and_finalize_is_idempotent() -> None:
- registry = EngineRegistry()
- registry.register(RegexEngine)
-
- assert registry.finalize() is registry
- assert registry.finalize() is registry
- with pytest.raises(EngineRegistryError):
- registry.register(DetectEngine)
-
-
-def test_registry_rejects_duplicate_discriminators_and_resource_mismatch() -> None:
- registry = EngineRegistry()
- registry.register(AcmeEngine, resources=AcmeResources(prefix="token"))
-
- with pytest.raises(EngineRegistryError):
- registry.register(AcmeEngine, resources=AcmeResources(prefix="other"))
- with pytest.raises(EngineRegistryError):
- EngineRegistry().register(AcmeEngine)
- with pytest.raises(EngineRegistryError, match="must extend EngineResources"):
- EngineRegistry().register(AcmeEngine, resources=object())
- with pytest.raises(EngineRegistryError):
- EngineRegistry().register(DetectEngine, resources=object())
-
-
-def _run_without_the_engine_wrapper(
- self: DetectEngine,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
-) -> TextProcessingResult:
- del self, strategy, timeout
- return TextProcessingResult(text=text, detections=())
-
-
-def _initialize_without_the_engine_constructor(
- self: DetectEngine,
- config: DetectConfig,
- resources: None,
-) -> None:
- del self, config, resources
-
-
-@pytest.mark.parametrize(
- ("method_name", "method", "expected_error"),
- [
- (
- "run",
- _run_without_the_engine_wrapper,
- "engine lifecycle contract requires EntityProcessingEngine.run; "
- "implement _run() instead",
- ),
- (
- "__init__",
- _initialize_without_the_engine_constructor,
- "engine lifecycle contract requires EntityProcessingEngine.__init__; "
- "use _initialize() instead",
- ),
- ],
-)
-def test_registry_rejects_direct_and_inherited_lifecycle_overrides(
- method_name: str,
- method: object,
- expected_error: str,
-) -> None:
- direct_override = type(
- "LifecycleOverrideEngine",
- (DetectEngine,),
- {method_name: method},
- )
- inherited_override = type(
- "InheritedOverrideEngine",
- (direct_override,),
- {},
- )
-
- for engine_type in (direct_override, inherited_override):
- with pytest.raises(EngineRegistryError) as error:
- EngineRegistry().register(engine_type)
-
- assert str(error.value) == expected_error
-
-
-def test_base_lifecycle_methods_are_final_for_static_feedback() -> None:
- assert getattr(EntityProcessingEngine.__init__, "__final__", False) is True
- assert getattr(EntityProcessingEngine.run, "__final__", False) is True
-
-
-def test_registry_accepts_base_lifecycle_inherited_through_custom_base() -> None:
- intermediate_base = type(
- "ValidIntermediateEngineBase",
- (DetectEngine,),
- {},
- )
- inherited_lifecycle_engine = type(
- "InheritedLifecycleEngine",
- (intermediate_base,),
- {},
- )
-
- registry = EngineRegistry()
- registry.register(inherited_lifecycle_engine)
-
- assert inherited_lifecycle_engine.__init__ is EntityProcessingEngine.__init__
- assert inherited_lifecycle_engine.run is EntityProcessingEngine.run
-
-
-@pytest.mark.parametrize(
- ("engine_type", "resources"),
- [
- (DetectEngine, None),
- (AcmeEngine, AcmeResources(prefix="token")),
- ],
-)
-def test_registry_accepts_engines_using_the_base_lifecycle(
- engine_type: type[object],
- resources: object,
-) -> None:
- registry = EngineRegistry()
-
- registry.register(engine_type, resources=resources)
-
- assert registry.finalize().is_finalized is True
-
-
-def test_describe_does_not_construct_an_engine() -> None:
- class CountingEngine(EntityProcessingEngine[DetectConfig]):
- supported_strategies = frozenset({EntityProcessingStrategy.DETECT})
- initialized = 0
-
- def _initialize(self) -> None:
- type(self).initialized += 1
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- del strategy, timeout
- return TextProcessingResult(text=text, detections=())
-
- registry = EngineRegistry()
- registry.register(CountingEngine)
- registry.finalize()
-
- descriptions = registry.describe_engines()
-
- assert CountingEngine.initialized == 0
- assert descriptions[0].engine_name == "detect-only"
- assert descriptions[0].supported_strategies == frozenset(
- {EntityProcessingStrategy.DETECT}
- )
-
-
-def test_registry_requires_at_least_one_engine() -> None:
- with pytest.raises(EngineRegistryError):
- EngineRegistry().finalize()
diff --git a/projects/privacy-guard/tests/examples/test_custom_engine.py b/projects/privacy-guard/tests/examples/test_custom_engine.py
deleted file mode 100644
index 0391b373..00000000
--- a/projects/privacy-guard/tests/examples/test_custom_engine.py
+++ /dev/null
@@ -1,147 +0,0 @@
-"""End-to-end checks for the custom engine application example."""
-
-from __future__ import annotations
-
-import json
-import os
-import subprocess
-import sys
-from pathlib import Path
-
-import yaml
-
-EXAMPLE_DIRECTORY = Path(__file__).parents[2] / "examples" / "custom-engine"
-
-
-def test_custom_engine_runs_through_the_middleware_boundary() -> None:
- probe = r"""
-import asyncio
-from pathlib import Path
-
-from google.protobuf import json_format
-import yaml
-
-from privacy_guard.bindings import supervisor_middleware_pb2 as pb2
-from privacy_guard.service.servicer import PrivacyGuardMiddleware
-from custom_engine import create_registry
-
-values = yaml.safe_load(Path("privacy-guard-config.yaml").read_text())
-assert isinstance(values, dict)
-config = pb2.HttpRequestEvaluation().config
-json_format.ParseDict(values, config)
-
-
-async def evaluate() -> None:
- middleware = PrivacyGuardMiddleware(create_registry())
- try:
- result = await middleware._evaluate_http_request(
- pb2.HttpRequestEvaluation(
- phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS,
- config=config,
- body=b"Discuss Project Cobalt safely.",
- )
- )
- finally:
- await middleware.close()
-
- assert result.decision == pb2.DECISION_ALLOW
- assert result.has_body is False
- assert result.body == b""
- assert len(result.findings) == 1
- assert result.findings[0].label == (
- "confidential-project (project-names)"
- )
-
-
-asyncio.run(evaluate())
-"""
-
- subprocess.run(
- [sys.executable, "-c", probe],
- cwd=EXAMPLE_DIRECTORY,
- check=True,
- )
-
-
-def test_custom_registry_drives_cli_discovery_and_schema() -> None:
- environment = os.environ.copy()
- python_path = str(EXAMPLE_DIRECTORY)
- existing_python_path = environment.get("PYTHONPATH")
- if existing_python_path:
- python_path = os.pathsep.join((python_path, existing_python_path))
- environment["PYTHONPATH"] = python_path
- command = [
- str(Path(sys.executable).with_name("privacy-guard")),
- "--registry-factory",
- "custom_engine:create_registry",
- ]
-
- engines = subprocess.run(
- [*command, "engines"],
- cwd=EXAMPLE_DIRECTORY,
- check=True,
- capture_output=True,
- text=True,
- env=environment,
- )
- schema = subprocess.run(
- [*command, "configuration-schema"],
- cwd=EXAMPLE_DIRECTORY,
- check=True,
- capture_output=True,
- text=True,
- env=environment,
- )
-
- assert engines.stdout.startswith("regex\tdetect,replace\t")
- assert "keyword-tool\tdetect\t" in engines.stdout
- serialized_schema = json.loads(schema.stdout)
- assert "RegexEngineConfig" in serialized_schema["$defs"]
- assert "KeywordEngineConfig" in serialized_schema["$defs"]
- keyword_properties = serialized_schema["$defs"]["KeywordEngineConfig"]["properties"]
- assert set(keyword_properties) == {
- "engine",
- "entity",
- "keyword",
- }
-
-
-def test_openshell_walkthrough_uses_the_custom_registry_and_current_policy() -> None:
- policy = yaml.safe_load((EXAMPLE_DIRECTORY / "policy.yaml").read_text())
- config = yaml.safe_load(
- (EXAMPLE_DIRECTORY / "privacy-guard-config.yaml").read_text()
- )
- readme = (EXAMPLE_DIRECTORY / "README.md").read_text()
- implementation = (EXAMPLE_DIRECTORY / "custom_engine.py").read_text()
-
- assert isinstance(policy, dict)
- assert isinstance(config, dict)
- assert not (EXAMPLE_DIRECTORY / "privacy_guard_app.py").exists()
- assert "EngineRegistry(include_builtin_engines=True)" in implementation
- assert "def create_registry() -> EngineRegistry:" in implementation
- middleware_config = policy["network_middlewares"]["privacy_guard_detect"]
- assert middleware_config["middleware"] == "privacy-guard-custom-engine"
- assert middleware_config["config"] == config
- stage_config = config["entity_processing"]["stages"][0]["config"]
- assert stage_config["engine"] == "keyword-tool"
- assert config["on_detection"]["action"] == "detect"
- assert "--registry-factory custom_engine:create_registry" in readme
- assert "cd projects/privacy-guard/examples/custom-engine" in readme
- assert "uv sync --locked" not in readme
- assert "uv run --locked privacy-guard" in readme
- assert 'export PYTHONPATH="$PWD${PYTHONPATH:+:$PYTHONPATH}"' in readme
- assert "uv run privacy-guard add-gateway-registration" in readme
- assert "uv run privacy-guard remove-gateway-registration" in readme
- assert "--host-ip YOUR_HOST_IPV4" in readme
- assert "--name privacy-guard-custom-engine" in readme
- assert "--config" not in readme
- assert "brew services stop openshell" in readme
- assert "brew services start openshell" in readme
- assert "systemctl --user stop openshell-gateway" in readme
- assert "systemctl --user start openshell-gateway" in readme
- assert "openshell-gateway --config" not in readme
- assert 'sed "s/REPLACE_WITH_HOST_IP/' not in readme
- assert not (EXAMPLE_DIRECTORY / "gateway.toml").exists()
- assert "openshell gateway add" not in readme
- assert "OpenShell `v0.0.90`" in readme
- assert "transformed:false" in readme
diff --git a/projects/privacy-guard/tests/examples/test_regex_engine.py b/projects/privacy-guard/tests/examples/test_regex_engine.py
deleted file mode 100644
index a235ea4f..00000000
--- a/projects/privacy-guard/tests/examples/test_regex_engine.py
+++ /dev/null
@@ -1,118 +0,0 @@
-"""End-to-end checks for the built-in RegexEngine example."""
-
-from __future__ import annotations
-
-import asyncio
-import json
-import subprocess
-import sys
-from pathlib import Path
-
-import pytest
-import yaml
-from google.protobuf import json_format
-
-from privacy_guard.bindings import supervisor_middleware_pb2 as pb2
-from privacy_guard.engines import RegexPatternCatalog
-from privacy_guard.engines.registry import create_builtin_registry
-from privacy_guard.service.servicer import PrivacyGuardMiddleware
-
-EXAMPLE_DIRECTORY = Path(__file__).parents[2] / "examples" / "regex-engine"
-
-
-def test_regex_example_runs_through_the_middleware_boundary(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- monkeypatch.chdir(EXAMPLE_DIRECTORY)
- values = yaml.safe_load(
- (EXAMPLE_DIRECTORY / "privacy-guard-config.yaml").read_text()
- )
- assert isinstance(values, dict)
- config = pb2.HttpRequestEvaluation().config
- json_format.ParseDict(values, config)
-
- async def evaluate() -> None:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- try:
- result = await middleware._evaluate_http_request(
- pb2.HttpRequestEvaluation(
- phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS,
- config=config,
- body=(b"Contact user@example.com about customer CUST-12345678."),
- )
- )
- finally:
- await middleware.close()
-
- assert result.decision == pb2.DECISION_ALLOW
- assert result.has_body is True
- assert result.body == (b"Contact [email] about customer [customer-id].")
- assert {finding.label for finding in result.findings} == {
- "email (identifiers)",
- "customer-id (identifiers)",
- }
-
- asyncio.run(evaluate())
-
-
-def test_builtin_registry_drives_documented_cli_discovery_and_schema() -> None:
- command = str(Path(sys.executable).with_name("privacy-guard"))
- engines = subprocess.run(
- [command, "engines"],
- cwd=EXAMPLE_DIRECTORY,
- check=True,
- capture_output=True,
- text=True,
- )
- schema = subprocess.run(
- [command, "configuration-schema"],
- cwd=EXAMPLE_DIRECTORY,
- check=True,
- capture_output=True,
- text=True,
- )
-
- assert engines.stdout.startswith("regex\tdetect,replace\t")
- serialized_schema = json.loads(schema.stdout)
- assert "RegexEngineConfig" in serialized_schema["$defs"]
- assert "RegexPatternCatalog" in serialized_schema["$defs"]
- assert "RegexRule" in serialized_schema["$defs"]
- assert "RegexReplacement" in serialized_schema["$defs"]
-
-
-def test_regex_walkthrough_uses_current_policy_and_gateway_schema() -> None:
- policy = yaml.safe_load((EXAMPLE_DIRECTORY / "policy.yaml").read_text())
- config = yaml.safe_load(
- (EXAMPLE_DIRECTORY / "privacy-guard-config.yaml").read_text()
- )
- catalog = yaml.safe_load((EXAMPLE_DIRECTORY / "patterns.yaml").read_text())
- readme = (EXAMPLE_DIRECTORY / "README.md").read_text()
-
- assert isinstance(policy, dict)
- assert isinstance(config, dict)
- assert isinstance(catalog, dict)
- middleware_config = policy["network_middlewares"]["privacy_guard_replace"]
- assert middleware_config["middleware"] == "privacy-guard-regex"
- assert middleware_config["config"] == config
- assert config["on_detection"]["action"] == "replace"
- stage_config = config["entity_processing"]["stages"][0]["config"]
- assert stage_config["engine"] == "regex"
- assert stage_config["pattern_catalog"] == "patterns.yaml"
- RegexPatternCatalog.model_validate(catalog)
- assert "uv sync --locked" not in readme
- assert "uv run --locked privacy-guard serve --listen 0.0.0.0:50051" in readme
- assert "uv run privacy-guard add-gateway-registration" in readme
- assert "uv run privacy-guard remove-gateway-registration" in readme
- assert "--host-ip YOUR_HOST_IPV4" in readme
- assert "--name privacy-guard-regex" in readme
- assert "--config" not in readme
- assert "brew services stop openshell" in readme
- assert "brew services start openshell" in readme
- assert "systemctl --user stop openshell-gateway" in readme
- assert "systemctl --user start openshell-gateway" in readme
- assert "openshell-gateway --config" not in readme
- assert 'sed "s/REPLACE_WITH_HOST_IP/' not in readme
- assert not (EXAMPLE_DIRECTORY / "gateway.toml").exists()
- assert "openshell gateway add" not in readme
- assert "OpenShell `v0.0.90`" in readme
- assert "transformed:true" in readme
diff --git a/projects/privacy-guard/tests/service/__init__.py b/projects/privacy-guard/tests/service/__init__.py
deleted file mode 100644
index c8634e6b..00000000
--- a/projects/privacy-guard/tests/service/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Privacy Guard service tests."""
diff --git a/projects/privacy-guard/tests/service/test_grpc_integration.py b/projects/privacy-guard/tests/service/test_grpc_integration.py
deleted file mode 100644
index 4113c1ea..00000000
--- a/projects/privacy-guard/tests/service/test_grpc_integration.py
+++ /dev/null
@@ -1,328 +0,0 @@
-"""Real loopback coverage for the generated OpenShell gRPC service."""
-
-from __future__ import annotations
-
-from collections.abc import AsyncIterator
-from contextlib import asynccontextmanager
-from typing import Literal
-
-import grpc
-import pytest
-from google.protobuf import empty_pb2, json_format, message_factory
-from google.protobuf.message import Message
-from pydantic import field_validator
-
-from privacy_guard.base import StrictDomainModel
-from privacy_guard.bindings import supervisor_middleware_pb2 as pb2
-from privacy_guard.bindings import supervisor_middleware_pb2_grpc as pb2_grpc
-from privacy_guard.engines import (
- EngineConfig,
- EntityProcessingEngine,
- EntityProcessingStrategy,
- TextProcessingResult,
-)
-from privacy_guard.engines.registry import EngineRegistry, create_builtin_registry
-from privacy_guard.errors import PrivacyGuardError
-from privacy_guard.service.servicer import PrivacyGuardMiddleware
-from privacy_guard.timeout import Timeout
-
-
-def _config(
- *,
- action: str = "replace",
- pattern: str = r"[a-z]+@[a-z]+\.[a-z]+",
-) -> pb2.ValidateConfigRequest:
- request = pb2.ValidateConfigRequest()
- json_format.ParseDict(
- {
- "entity_processing": {
- "stages": [
- {
- "name": "identifiers",
- "config": {
- "engine": "regex",
- "pattern_catalog": {
- "entities": [
- {
- "name": "email",
- "rules": [
- {
- "pattern": pattern,
- "confidence": "high",
- }
- ],
- }
- ]
- },
- "replacement": {
- "strategy": "template",
- "template": "[{entity}]",
- },
- },
- }
- ]
- },
- "on_detection": {"action": action},
- },
- request.config,
- )
- return request
-
-
-def _config_with_stages(stage_count: int) -> pb2.ValidateConfigRequest:
- values = json_format.MessageToDict(_config(action="detect").config)
- stage = values["entity_processing"]["stages"][0]
- stage.pop("name")
- values["entity_processing"]["stages"] = [stage] * stage_count
- request = pb2.ValidateConfigRequest()
- json_format.ParseDict(values, request.config)
- return request
-
-
-def _evaluation(
- body: bytes,
- *,
- action: str = "replace",
- phase: pb2.SupervisorMiddlewarePhase = (
- pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS
- ),
-) -> pb2.HttpRequestEvaluation:
- return pb2.HttpRequestEvaluation(
- phase=phase,
- context=pb2.RequestContext(request_id="grpc-integration"),
- config=_config(action=action).config,
- body=body,
- )
-
-
-@asynccontextmanager
-async def _running_stub(
- middleware: PrivacyGuardMiddleware,
-) -> AsyncIterator[pb2_grpc.SupervisorMiddlewareStub]:
- server = grpc.aio.server()
- pb2_grpc.add_SupervisorMiddlewareServicer_to_server(middleware, server)
- 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)
- finally:
- await channel.close()
- await server.stop(grace=0)
- await middleware.close()
-
-
-@pytest.mark.asyncio
-async def test_generated_stub_round_trip_covers_manifest_config_and_actions() -> None:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- async with _running_stub(middleware) as stub:
- empty_message_type = message_factory.GetMessageClass(
- empty_pb2.DESCRIPTOR.message_types_by_name["Empty"]
- )
- empty_message: Message = empty_message_type()
- manifest = await stub.Describe(empty_message)
- valid = await stub.ValidateConfig(_config())
- invalid = await stub.ValidateConfig(pb2.ValidateConfigRequest())
- detected = await stub.EvaluateHttpRequest(
- _evaluation(b"contact a@b.com", action="detect")
- )
- replaced = await stub.EvaluateHttpRequest(_evaluation(b"contact a@b.com"))
- blocked = await stub.EvaluateHttpRequest(
- _evaluation(b"contact a@b.com", action="block")
- )
- clean = await stub.EvaluateHttpRequest(_evaluation(b"no match", action="block"))
-
- assert manifest.name == "privacy-guard"
- assert len(manifest.bindings) == 1
- assert valid.valid is True
- assert invalid.valid is False
- assert "config_invalid" in invalid.reason
- assert detected.decision == pb2.DECISION_ALLOW
- assert detected.has_body is False
- assert len(detected.findings) == 1
- assert replaced.decision == pb2.DECISION_ALLOW
- assert replaced.has_body is True
- assert replaced.body == b"contact [email]"
- assert blocked.decision == pb2.DECISION_DENY
- assert blocked.reason_code == "privacy_guard_blocked"
- assert clean.decision == pb2.DECISION_ALLOW
-
-
-@pytest.mark.asyncio
-async def test_generated_stub_maps_invalid_and_internal_failures(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- async with _running_stub(middleware) as stub:
- with pytest.raises(grpc.aio.AioRpcError) as invalid:
- await stub.EvaluateHttpRequest(
- _evaluation(
- b"body",
- phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED,
- )
- )
- assert invalid.value.code() is grpc.StatusCode.INVALID_ARGUMENT
- assert "request_phase_invalid" in (invalid.value.details() or "")
-
- def fail_unexpectedly(values: object, body: bytes) -> None:
- del values, body
- raise RuntimeError
-
- monkeypatch.setattr(middleware, "_prepare_and_process", fail_unexpectedly)
- with pytest.raises(grpc.aio.AioRpcError) as internal:
- await stub.EvaluateHttpRequest(_evaluation(b"body"))
- assert internal.value.code() is grpc.StatusCode.INTERNAL
- assert "unexpected_service_failure" in (internal.value.details() or "")
-
-
-@pytest.mark.asyncio
-async def test_generated_stub_enforces_ten_stage_limit() -> None:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- async with _running_stub(middleware) as stub:
- exact_config = _config_with_stages(10)
- oversized_config = _config_with_stages(11)
- exact_validation = await stub.ValidateConfig(exact_config)
- oversized_validation = await stub.ValidateConfig(oversized_config)
- exact_evaluation = _evaluation(b"no match", action="detect")
- exact_evaluation.config.CopyFrom(exact_config.config)
- exact_result = await stub.EvaluateHttpRequest(exact_evaluation)
- oversized_evaluation = _evaluation(b"no match", action="detect")
- oversized_evaluation.config.CopyFrom(oversized_config.config)
- with pytest.raises(grpc.aio.AioRpcError) as oversized_result:
- await stub.EvaluateHttpRequest(oversized_evaluation)
-
- assert exact_validation.valid is True
- assert oversized_validation.valid is False
- assert exact_result.decision == pb2.DECISION_ALLOW
- assert oversized_result.value.code() is grpc.StatusCode.INVALID_ARGUMENT
- assert "config_invalid" in (oversized_result.value.details() or "")
-
-
-@pytest.mark.asyncio
-async def test_generated_stub_maps_contextual_zero_width_to_invalid_config() -> None:
- report_pattern = "x|(?=SECRET-zero-width-493)"
- config = _config(action="detect", pattern=report_pattern)
- evaluation = _evaluation(b"SECRET-zero-width-493", action="detect")
- evaluation.config.CopyFrom(config.config)
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
-
- async with _running_stub(middleware) as stub:
- before = await stub.EvaluateHttpRequest(
- _evaluation(b"contact a@b.com", action="detect")
- )
- validation = await stub.ValidateConfig(config)
- with pytest.raises(grpc.aio.AioRpcError) as evaluation_error:
- await stub.EvaluateHttpRequest(evaluation)
- after = await stub.EvaluateHttpRequest(
- _evaluation(b"contact a@b.com", action="detect")
- )
-
- details = evaluation_error.value.details() or ""
- assert len(before.findings) == 1
- assert validation.valid is True
- assert evaluation_error.value.code() is grpc.StatusCode.INVALID_ARGUMENT
- assert "config_invalid" in details
- assert "engine_execution_failed" not in details
- assert report_pattern not in details
- assert len(after.findings) == 1
-
-
-class _NumericNestedConfig(StrictDomainModel):
- count: int
-
-
-class _NumericEngineConfig(EngineConfig):
- engine: Literal["numeric"] = "numeric"
- threshold: int
- ratio: float
- nested: _NumericNestedConfig
- values: tuple[int, ...]
-
- @field_validator("values", mode="before")
- @classmethod
- def _values_are_a_tuple(cls, value: object) -> object:
- if not isinstance(value, list | tuple):
- raise ValueError("values must be a list")
- return tuple(value)
-
-
-class _NumericEngine(EntityProcessingEngine[_NumericEngineConfig]):
- supported_strategies = frozenset({EntityProcessingStrategy.DETECT})
-
- def _run(
- self,
- text: str,
- *,
- strategy: EntityProcessingStrategy,
- timeout: Timeout,
- ) -> TextProcessingResult:
- del strategy, timeout
- return TextProcessingResult(text=text, detections=())
-
-
-def _numeric_values(
- threshold: int | float,
- *,
- ratio: float = 3.0,
-) -> dict[str, object]:
- return {
- "entity_processing": {
- "stages": [
- {
- "config": {
- "engine": "numeric",
- "threshold": threshold,
- "ratio": ratio,
- "nested": {"count": 4},
- "values": [5, 6],
- }
- }
- ]
- },
- "on_detection": {"action": "detect"},
- }
-
-
-def _numeric_request(
- threshold: int | float,
- *,
- ratio: float = 3.0,
-) -> pb2.ValidateConfigRequest:
- request = pb2.ValidateConfigRequest()
- json_format.ParseDict(_numeric_values(threshold, ratio=ratio), request.config)
- return request
-
-
-def _numeric_registry() -> EngineRegistry:
- registry = EngineRegistry()
- registry.register(_NumericEngine)
- return registry.finalize()
-
-
-@pytest.mark.asyncio
-async def test_generated_stub_normalizes_transport_safe_integral_numbers() -> None:
- registry = _numeric_registry()
- with pytest.raises(PrivacyGuardError):
- registry.validate_config(_numeric_values(3.0))
-
- middleware = PrivacyGuardMiddleware(registry)
- async with _running_stub(middleware) as stub:
- ordinary = await stub.ValidateConfig(_numeric_request(3, ratio=3.5))
- safe_max = await stub.ValidateConfig(_numeric_request((1 << 53) - 1))
- safe_min = await stub.ValidateConfig(_numeric_request(-((1 << 53) - 1)))
- non_integral = await stub.ValidateConfig(_numeric_request(3.5))
- beyond_safe = await stub.ValidateConfig(_numeric_request(1 << 53))
- evaluation = pb2.HttpRequestEvaluation(
- phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS,
- config=_numeric_request(3).config,
- body=b"body",
- )
- result = await stub.EvaluateHttpRequest(evaluation)
-
- assert ordinary.valid is True
- assert safe_max.valid is True
- assert safe_min.valid is True
- assert non_integral.valid is False
- assert beyond_safe.valid is False
- assert result.decision == pb2.DECISION_ALLOW
diff --git a/projects/privacy-guard/tests/service/test_server.py b/projects/privacy-guard/tests/service/test_server.py
deleted file mode 100644
index 3147cfec..00000000
--- a/projects/privacy-guard/tests/service/test_server.py
+++ /dev/null
@@ -1,406 +0,0 @@
-"""Programmatic Privacy Guard server lifecycle tests."""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-import subprocess
-import sys
-
-import grpc
-import pytest
-
-from privacy_guard.constants import MAX_CONCURRENT_RPCS, MAX_RECEIVE_MESSAGE_BYTES
-from privacy_guard.engines.registry import EngineRegistry, create_builtin_registry
-from privacy_guard.errors import EngineRegistryError, ErrorCode, PrivacyGuardError
-from privacy_guard.service import server as server_module
-from privacy_guard.service.server import PrivacyGuardServer
-from privacy_guard.service.servicer import PrivacyGuardMiddleware
-
-
-class _LifecycleServerFake:
- """Minimal async-server fake for lifecycle-only tests."""
-
- def __init__(
- self,
- *,
- bound_port: int = 50051,
- bind_error: RuntimeError | None = None,
- start_error: RuntimeError | None = None,
- wait_error: BaseException | None = None,
- block_stop: bool = False,
- ) -> None:
- self.bound_port = bound_port
- self.bind_error = bind_error
- self.start_error = start_error
- self.wait_error = wait_error
- self.addresses: list[str] = []
- self.started = False
- self.waited = False
- self.stop_graces: list[float | None] = []
- self.stop_started = asyncio.Event()
- self.stop_release = asyncio.Event()
- if not block_stop:
- self.stop_release.set()
-
- def add_insecure_port(self, address: str) -> int:
- self.addresses.append(address)
- if self.bind_error is not None:
- raise self.bind_error
- return self.bound_port
-
- async def start(self) -> None:
- if self.start_error is not None:
- raise self.start_error
- self.started = True
-
- async def wait_for_termination(self, timeout: float | None = None) -> bool:
- del timeout
- if self.wait_error is not None:
- raise self.wait_error
- self.waited = True
- return True
-
- async def stop(self, grace: float | None) -> None:
- self.stop_graces.append(grace)
- self.stop_started.set()
- await self.stop_release.wait()
-
-
-def test_programmatic_server_runs_with_injected_registry_and_default_address(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- registry = create_builtin_registry()
- served: list[tuple[PrivacyGuardServer, str]] = []
-
- async def record_serve(self: PrivacyGuardServer, listen: str) -> None:
- served.append((self, listen))
- await self._middleware.close()
-
- monkeypatch.setattr(PrivacyGuardServer, "serve_async", record_serve)
-
- server = PrivacyGuardServer(
- registry=registry,
- timeout_seconds=4.5,
- log_request_content=True,
- )
- server.serve_sync()
-
- assert served == [(server, "127.0.0.1:50051")]
- assert server._middleware._registry is registry
- assert server._middleware._policy._timeout_seconds == 4.5
- assert server._middleware._policy._log_request_content is True
-
-
-def test_programmatic_server_requires_an_explicit_finalized_registry() -> None:
- with pytest.raises(EngineRegistryError, match="finalized"):
- PrivacyGuardServer(EngineRegistry())
-
-
-@pytest.mark.parametrize("timeout_seconds", [True, 0, 31, float("inf")])
-def test_programmatic_server_rejects_invalid_processing_timeout(
- timeout_seconds: bool | int | float,
-) -> None:
- with pytest.raises(
- ValueError,
- match="finite number greater than 0 and at most 30",
- ):
- PrivacyGuardServer(
- create_builtin_registry(),
- timeout_seconds=timeout_seconds,
- )
-
-
-def test_synchronous_server_exits_cleanly_after_keyboard_interrupt(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- server = PrivacyGuardServer(create_builtin_registry())
-
- async def interrupt(self: PrivacyGuardServer, listen: str) -> None:
- del self, listen
- raise KeyboardInterrupt
-
- monkeypatch.setattr(PrivacyGuardServer, "serve_async", interrupt)
-
- server.serve_sync()
- asyncio.run(server._middleware.close())
-
-
-def test_programmatic_server_import_does_not_load_the_cli_framework() -> None:
- probe = (
- "import sys; "
- "from privacy_guard.service import PrivacyGuardServer; "
- "assert PrivacyGuardServer.__name__ == 'PrivacyGuardServer'; "
- "assert 'privacy_guard.cli' not in sys.modules; "
- "assert 'typer' not in sys.modules"
- )
-
- subprocess.run([sys.executable, "-c", probe], check=True)
-
-
-def test_engine_import_does_not_load_the_server_transport() -> None:
- probe = (
- "import sys; "
- "import privacy_guard.engines; "
- "assert 'privacy_guard.service' not in sys.modules; "
- "assert 'grpc' not in sys.modules"
- )
-
- subprocess.run([sys.executable, "-c", probe], check=True)
-
-
-def test_server_sets_transport_limits_and_registers_middleware(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- fake_server = object()
- server_options: list[tuple[int, tuple[tuple[str, int], ...]]] = []
- registrations: list[tuple[PrivacyGuardMiddleware, object]] = []
-
- def fake_server_factory(
- *,
- maximum_concurrent_rpcs: int,
- options: tuple[tuple[str, int], ...],
- ) -> object:
- server_options.append((maximum_concurrent_rpcs, options))
- return fake_server
-
- def record_registration(
- middleware: PrivacyGuardMiddleware,
- server: object,
- ) -> None:
- registrations.append((middleware, server))
-
- middleware = _middleware()
- monkeypatch.setattr(grpc.aio, "server", fake_server_factory)
- monkeypatch.setattr(
- server_module.pb2_grpc,
- "add_SupervisorMiddlewareServicer_to_server",
- record_registration,
- )
- try:
- result = server_module._create_grpc_server(middleware)
- finally:
- asyncio.run(middleware.close())
-
- assert result is fake_server
- assert server_options == [
- (
- MAX_CONCURRENT_RPCS,
- (("grpc.max_receive_message_length", MAX_RECEIVE_MESSAGE_BYTES),),
- )
- ]
- assert registrations == [(middleware, fake_server)]
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- ("fake_server", "sensitive_address"),
- [
- (_LifecycleServerFake(bound_port=0), "invalid-sensitive-listen-8472"),
- (
- _LifecycleServerFake(
- bind_error=RuntimeError("invalid-sensitive-listen-9472")
- ),
- "invalid-sensitive-listen-9472",
- ),
- ],
-)
-async def test_serve_async_sanitizes_bind_failures_and_closes_resources(
- monkeypatch: pytest.MonkeyPatch,
- fake_server: _LifecycleServerFake,
- sensitive_address: str,
-) -> None:
- closed: list[PrivacyGuardMiddleware] = []
-
- async def record_close(middleware: PrivacyGuardMiddleware) -> None:
- closed.append(middleware)
-
- server = PrivacyGuardServer(create_builtin_registry())
- monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server)
- monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close)
-
- with pytest.raises(PrivacyGuardError) as captured:
- await server.serve_async(sensitive_address)
-
- assert captured.value.code is ErrorCode.SERVER_BIND_FAILED
- assert captured.value.__cause__ is None
- assert sensitive_address not in str(captured.value)
- assert fake_server.started is False
- assert fake_server.waited is False
- assert fake_server.stop_graces == [0]
- assert closed == [server._middleware]
-
-
-@pytest.mark.asyncio
-async def test_serve_async_starts_waits_and_closes_on_normal_termination(
- monkeypatch: pytest.MonkeyPatch,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- fake_server = _LifecycleServerFake(bound_port=50053)
- closed: list[PrivacyGuardMiddleware] = []
-
- async def record_close(middleware: PrivacyGuardMiddleware) -> None:
- closed.append(middleware)
-
- server = PrivacyGuardServer(create_builtin_registry())
- monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server)
- monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close)
-
- with caplog.at_level(logging.INFO, logger="privacy_guard.service.server"):
- await server.serve_async("127.0.0.1:50053")
-
- assert fake_server.addresses == ["127.0.0.1:50053"]
- assert fake_server.started is True
- assert fake_server.waited is True
- assert fake_server.stop_graces == [0]
- assert closed == [server._middleware]
- assert "privacy_guard_server_bound listen='127.0.0.1:50053'" in caplog.text
-
-
-@pytest.mark.asyncio
-async def test_serve_async_propagates_cancellation_after_closing_resources(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- fake_server = _LifecycleServerFake(
- bound_port=50054,
- wait_error=asyncio.CancelledError(),
- )
- closed: list[PrivacyGuardMiddleware] = []
-
- async def record_close(middleware: PrivacyGuardMiddleware) -> None:
- closed.append(middleware)
-
- server = PrivacyGuardServer(create_builtin_registry())
- monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server)
- monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close)
-
- with pytest.raises(asyncio.CancelledError):
- await server.serve_async("127.0.0.1:50054")
-
- assert fake_server.started is True
- assert fake_server.stop_graces == [0]
- assert closed == [server._middleware]
-
-
-@pytest.mark.asyncio
-async def test_serve_async_preserves_cancellation_during_server_shutdown(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- fake_server = _LifecycleServerFake(bound_port=50055, block_stop=True)
- closed: list[PrivacyGuardMiddleware] = []
-
- async def record_close(middleware: PrivacyGuardMiddleware) -> None:
- closed.append(middleware)
-
- server = PrivacyGuardServer(create_builtin_registry())
- monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server)
- monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close)
-
- serving = asyncio.create_task(server.serve_async("127.0.0.1:50055"))
- await fake_server.stop_started.wait()
- serving.cancel()
- await asyncio.sleep(0)
-
- assert serving.done() is False
-
- fake_server.stop_release.set()
- with pytest.raises(asyncio.CancelledError):
- await serving
-
- assert fake_server.stop_graces == [0]
- assert closed == [server._middleware]
-
-
-@pytest.mark.asyncio
-async def test_serve_async_sanitizes_startup_failures_and_closes_resources(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- fake_server = _LifecycleServerFake(
- bound_port=50056,
- start_error=RuntimeError("startup failed"),
- )
- closed: list[PrivacyGuardMiddleware] = []
-
- async def record_close(middleware: PrivacyGuardMiddleware) -> None:
- closed.append(middleware)
-
- server = PrivacyGuardServer(create_builtin_registry())
- monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server)
- monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close)
-
- with pytest.raises(PrivacyGuardError) as captured:
- await server.serve_async("127.0.0.1:50056")
-
- assert captured.value.code is ErrorCode.SERVER_BIND_FAILED
- assert captured.value.__cause__ is None
- assert "server.start" in str(captured.value)
- assert "startup failed" not in str(captured.value)
- assert fake_server.waited is False
- assert fake_server.stop_graces == [0]
- assert closed == [server._middleware]
-
-
-@pytest.mark.parametrize(
- ("listen", "port"),
- [
- ("127.0.0.1:1", 1),
- ("middleware.local:65535", 65_535),
- ("[::1]:50051", 50_051),
- ],
-)
-def test_listen_address_accepts_supported_tcp_forms(listen: str, port: int) -> None:
- assert server_module._validated_listen_port(listen) == port
-
-
-@pytest.mark.parametrize(
- "listen",
- [
- "127.0.0.1:0",
- "127.0.0.1:65536",
- "127.0.0.1:99999",
- "127.0.0.1:-1",
- "[::1]",
- "::1:50051",
- ],
-)
-def test_listen_address_rejects_invalid_numeric_ports_and_forms(
- listen: str,
-) -> None:
- with pytest.raises(PrivacyGuardError) as captured:
- server_module._validated_listen_port(listen)
-
- assert captured.value.code is ErrorCode.SERVER_BIND_FAILED
-
-
-def test_listen_address_rejects_arbitrarily_long_decimal_port() -> None:
- with pytest.raises(PrivacyGuardError) as captured:
- server_module._validated_listen_port(f"127.0.0.1:{'9' * 5_000}")
-
- assert captured.value.code is ErrorCode.SERVER_BIND_FAILED
-
-
-@pytest.mark.asyncio
-async def test_serve_async_rejects_mismatched_bound_port(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- fake_server = _LifecycleServerFake(bound_port=34_463)
- closed: list[PrivacyGuardMiddleware] = []
-
- async def record_close(middleware: PrivacyGuardMiddleware) -> None:
- closed.append(middleware)
-
- server = PrivacyGuardServer(create_builtin_registry())
- monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server)
- monkeypatch.setattr(PrivacyGuardMiddleware, "close", record_close)
-
- with pytest.raises(PrivacyGuardError) as captured:
- await server.serve_async("127.0.0.1:9999")
-
- assert captured.value.code is ErrorCode.SERVER_BIND_FAILED
- assert fake_server.started is False
- assert fake_server.stop_graces == [0]
- assert closed == [server._middleware]
-
-
-def _middleware() -> PrivacyGuardMiddleware:
- return PrivacyGuardMiddleware(create_builtin_registry())
diff --git a/projects/privacy-guard/tests/service/test_servicer.py b/projects/privacy-guard/tests/service/test_servicer.py
deleted file mode 100644
index 205c7aa3..00000000
--- a/projects/privacy-guard/tests/service/test_servicer.py
+++ /dev/null
@@ -1,813 +0,0 @@
-"""Service boundary tests over the canonical OpenShell-owned protobuf."""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-from concurrent.futures import ThreadPoolExecutor
-from copy import deepcopy
-from threading import Barrier, Event, Lock, get_ident
-from typing import Never
-
-import grpc
-import pytest
-from google.protobuf import json_format
-from google.protobuf.message import Message
-
-from privacy_guard.bindings import supervisor_middleware_pb2 as pb2
-from privacy_guard.config import PrivacyGuardConfig
-from privacy_guard.constants import (
- LIMIT_REASON,
- LIMIT_REASON_CODE,
- MAX_DIAGNOSTIC_TEXT_BYTES,
- MAX_PROTO_CONFIG_BYTES,
- MAX_PROTO_CONTEXT_BYTES,
- MAX_PROTO_FINDING_BYTES,
- MAX_PROTO_HEADERS,
- MAX_PROTO_HEADERS_BYTES,
- MAX_PROTO_TARGET_BYTES,
-)
-from privacy_guard.engines import (
- EngineConfig,
-)
-from privacy_guard.engines import regex as regex_module
-from privacy_guard.engines.registry import create_builtin_registry
-from privacy_guard.errors import ErrorCode, PrivacyGuardError
-from privacy_guard.request_processor import (
- EntityDetectionSummary,
- RequestDecision,
- RequestProcessingResult,
- RequestProcessor,
-)
-from privacy_guard.service import servicer as servicer_module
-from privacy_guard.service.servicer import PrivacyGuardMiddleware
-
-
-def _values(
- action: str = "replace",
- *,
- rules: list[dict[str, object]] | None = None,
- stage_count: int = 1,
- stage_name: str | None = None,
-) -> dict[str, object]:
- if rules is None:
- rules = [
- {
- "pattern": r"[a-z]+@[a-z]+\.[a-z]+",
- "confidence": "high",
- }
- ]
- stage: dict[str, object] = {
- "config": {
- "engine": "regex",
- "pattern_catalog": {
- "entities": [
- {
- "name": "email",
- "rules": rules,
- }
- ]
- },
- "replacement": {
- "strategy": "template",
- "template": "[{entity}]",
- },
- }
- }
- if stage_name is not None:
- stage["name"] = stage_name
- return {
- "entity_processing": {"stages": [deepcopy(stage) for _ in range(stage_count)]},
- "on_detection": {"action": action},
- }
-
-
-def _proto_config(values: dict[str, object]) -> Message:
- result = pb2.ValidateConfigRequest().config
- json_format.ParseDict(values, result)
- return result
-
-
-def _request(body: bytes, *, action: str = "replace") -> pb2.HttpRequestEvaluation:
- return pb2.HttpRequestEvaluation(
- phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS,
- config=_proto_config(_values(action)),
- body=body,
- )
-
-
-class _SuccessfulEvaluationContext:
- async def abort(self, code: grpc.StatusCode, details: str) -> Never:
- del code, details
- raise AssertionError("successful evaluation unexpectedly aborted")
-
-
-def test_copied_proto_remains_the_current_openshell_contract() -> None:
- evaluation = pb2.HttpRequestEvaluation()
- finding = pb2.Finding()
-
- assert isinstance(evaluation.config, Message)
- assert not hasattr(evaluation, "config_fingerprint")
- assert not hasattr(finding, "source")
-
-
-def test_validate_config_is_pure_and_reports_invalid_config(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- active = middleware._policy.processor_for(_values(action="replace"))
- processor_build_count = 0
- original_build = servicer_module._ActivePolicy._build_processor
-
- def record_processor_build(
- policy: servicer_module._ActivePolicy,
- config: PrivacyGuardConfig[EngineConfig],
- ) -> RequestProcessor:
- nonlocal processor_build_count
- processor_build_count += 1
- return original_build(policy, config)
-
- monkeypatch.setattr(
- servicer_module._ActivePolicy,
- "_build_processor",
- record_processor_build,
- )
- try:
- valid = middleware._validate_config(
- pb2.ValidateConfigRequest(config=_proto_config(_values("detect")))
- )
- invalid = middleware._validate_config(
- pb2.ValidateConfigRequest(config=_proto_config({"on_detection": {}}))
- )
- still_active = middleware._policy.processor_for(_values(action="replace"))
- finally:
- asyncio.run(middleware.close())
-
- assert valid.valid is True
- assert invalid.valid is False
- assert "config_invalid" in invalid.reason
- assert still_active is active
- assert processor_build_count == 0
-
-
-def test_validate_config_rejects_oversized_proto_before_registry_validation(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- validation_count = 0
- original_validate = servicer_module.EngineRegistry.validate_config
-
- def record_validation(
- registry: servicer_module.EngineRegistry,
- values: object,
- ) -> PrivacyGuardConfig[EngineConfig]:
- nonlocal validation_count
- validation_count += 1
- return original_validate(registry, values)
-
- monkeypatch.setattr(
- servicer_module.EngineRegistry,
- "validate_config",
- record_validation,
- )
- exact_config = pb2.ValidateConfigRequest()
- json_format.ParseDict({"padding": "x" * 65_515}, exact_config.config)
- oversized_config = pb2.ValidateConfigRequest()
- json_format.ParseDict({"padding": "x" * 65_516}, oversized_config.config)
- assert exact_config.config.ByteSize() == MAX_PROTO_CONFIG_BYTES
- assert oversized_config.config.ByteSize() == MAX_PROTO_CONFIG_BYTES + 1
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- try:
- exact = middleware._validate_config(exact_config)
- oversized = middleware._validate_config(oversized_config)
- finally:
- asyncio.run(middleware.close())
-
- assert exact.valid is False
- assert oversized.valid is False
- assert validation_count == 1
-
-
-@pytest.mark.parametrize(
- "unsafe_value",
- [
- "line\nbreak",
- "ansi\x1b[31m",
- "nul\x00byte",
- "right-to-left\u202eoverride",
- ],
-)
-def test_validate_config_rejects_non_printable_stage_names(
- unsafe_value: str,
-) -> None:
- registry = create_builtin_registry()
-
- with pytest.raises(PrivacyGuardError) as captured:
- registry.validate_config(_values(stage_name=unsafe_value))
-
- assert captured.value.code is ErrorCode.CONFIG_INVALID
-
-
-def test_validate_config_accepts_printable_unicode_stage_names() -> None:
- config = create_builtin_registry().validate_config(_values(stage_name="身份检查 🛡️"))
-
- assert config.entity_processing.stages[0].name == "身份检查 🛡️"
-
-
-def test_evaluation_enforces_exact_encoded_transport_boundaries() -> None:
- request = _request(b"")
- request.context.request_id = "x" * 4_093
- assert request.context.ByteSize() == MAX_PROTO_CONTEXT_BYTES
- servicer_module._validate_evaluation_envelope(request)
- request.context.request_id += "x"
- assert request.context.ByteSize() == MAX_PROTO_CONTEXT_BYTES + 1
- with pytest.raises(PrivacyGuardError) as context_error:
- servicer_module._validate_evaluation_envelope(request)
- assert context_error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID
-
- request = _request(b"")
- request.target.host = "x" * 32_764
- assert request.target.ByteSize() == MAX_PROTO_TARGET_BYTES
- servicer_module._validate_evaluation_envelope(request)
- request.target.host += "x"
- assert request.target.ByteSize() == MAX_PROTO_TARGET_BYTES + 1
- with pytest.raises(PrivacyGuardError) as target_error:
- servicer_module._validate_evaluation_envelope(request)
- assert target_error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID
-
- request = _request(b"")
- request.headers.add(name="x", value="x" * 65_525)
- assert servicer_module._encoded_headers_size(request.headers) == (
- MAX_PROTO_HEADERS_BYTES
- )
- servicer_module._validate_evaluation_envelope(request)
- request.headers[0].value += "x"
- assert servicer_module._encoded_headers_size(request.headers) == (
- MAX_PROTO_HEADERS_BYTES + 1
- )
- with pytest.raises(PrivacyGuardError) as header_size_error:
- servicer_module._validate_evaluation_envelope(request)
- assert header_size_error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID
-
- request = _request(b"")
- for _ in range(MAX_PROTO_HEADERS):
- request.headers.add()
- servicer_module._validate_evaluation_envelope(request)
- request.headers.add()
- with pytest.raises(PrivacyGuardError) as header_count_error:
- servicer_module._validate_evaluation_envelope(request)
- assert header_count_error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID
-
-
-def test_evaluation_enforces_exact_encoded_config_boundary() -> None:
- request = _request(b"")
- request.config.Clear()
- json_format.ParseDict({"padding": "x" * 65_515}, request.config)
- assert request.config.ByteSize() == MAX_PROTO_CONFIG_BYTES
- servicer_module._validate_evaluation_envelope(request)
- request.config.Clear()
- json_format.ParseDict({"padding": "x" * 65_516}, request.config)
- assert request.config.ByteSize() == MAX_PROTO_CONFIG_BYTES + 1
-
- with pytest.raises(PrivacyGuardError) as captured:
- servicer_module._validate_evaluation_envelope(request)
-
- assert captured.value.code is ErrorCode.CONFIG_INVALID
- assert "encoded configuration at or below 64 KiB" in str(captured.value)
-
-
-def test_limit_deny_explains_recovery_options() -> None:
- result = servicer_module._result_to_proto(
- RequestProcessingResult(
- decision=RequestDecision.DENY,
- reason_code=LIMIT_REASON_CODE,
- )
- )
-
- assert result.reason == LIMIT_REASON
- assert "Check Privacy Guard logs for the limit kind" in result.reason
- assert "Reduce the request or replacement size" in result.reason
- assert "simplify the configured stages and rules" in result.reason
- assert "--timeout-seconds or PrivacyGuardServer(timeout_seconds=...)" in (
- result.reason
- )
- assert "additional headroom for queueing and configuration preparation" in (
- result.reason
- )
-
-
-def test_service_limit_deny_logs_a_content_safe_resource_kind(
- caplog: pytest.LogCaptureFixture,
-) -> None:
- sentinel = "sensitive-finding-value"
- with caplog.at_level(logging.INFO, logger="privacy_guard.service.servicer"):
- result = servicer_module._result_to_proto(
- RequestProcessingResult(
- decision=RequestDecision.ALLOW,
- detection_summaries=(
- EntityDetectionSummary(
- entity=sentinel + ("x" * MAX_PROTO_FINDING_BYTES),
- source_stage="stage",
- count=1,
- ),
- ),
- )
- )
-
- assert result.reason_code == LIMIT_REASON_CODE
- assert "privacy_guard_processing_limit kind=resource" in caplog.text
- assert sentinel not in caplog.text
-
-
-@pytest.mark.parametrize(
- "invalid_request_id",
- [
- "line\nbreak",
- "ansi\x1b[31m",
- "nul\x00byte",
- "right-to-left\u202eoverride",
- "x" * (MAX_DIAGNOSTIC_TEXT_BYTES + 1),
- ],
-)
-def test_evaluation_logs_placeholder_for_invalid_request_id(
- caplog: pytest.LogCaptureFixture,
- invalid_request_id: str,
-) -> None:
- async def evaluate() -> pb2.HttpRequestResult:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- request = _request(b"no match", action="detect")
- request.context.request_id = invalid_request_id
- try:
- return await middleware._evaluate_rpc(
- request,
- _SuccessfulEvaluationContext(),
- )
- finally:
- await middleware.close()
-
- with caplog.at_level(logging.INFO, logger="privacy_guard.service.servicer"):
- result = asyncio.run(evaluate())
-
- records = [
- record
- for record in caplog.records
- if record.name == "privacy_guard.service.servicer"
- and record.getMessage().startswith("privacy_guard_evaluation ")
- ]
- assert result.decision == pb2.DECISION_ALLOW
- assert len(records) == 1
- assert 'request_id="invalid" ' in records[0].getMessage()
- assert records[0].getMessage().isprintable()
- assert len(caplog.text.splitlines()) == 1
-
-
-def test_evaluation_logs_printable_unicode_request_id(
- caplog: pytest.LogCaptureFixture,
-) -> None:
- async def evaluate() -> None:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- request = _request(b"no match", action="detect")
- request.context.request_id = "请求-42 🛡️"
- try:
- await middleware._evaluate_rpc(
- request,
- _SuccessfulEvaluationContext(),
- )
- finally:
- await middleware.close()
-
- with caplog.at_level(logging.INFO, logger="privacy_guard.service.servicer"):
- asyncio.run(evaluate())
-
- assert r'request_id="请求-42\u0020🛡️"' in caplog.text
- assert len(caplog.text.splitlines()) == 1
-
-
-def test_evaluation_quotes_request_id_delimiters_in_message_log(
- caplog: pytest.LogCaptureFixture,
-) -> None:
- request_id = 'trusted action=allow error_code="none"'
-
- async def evaluate() -> None:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- request = _request(b"no match", action="detect")
- request.context.request_id = request_id
- try:
- await middleware._evaluate_rpc(
- request,
- _SuccessfulEvaluationContext(),
- )
- finally:
- await middleware.close()
-
- with caplog.at_level(logging.INFO, logger="privacy_guard.service.servicer"):
- asyncio.run(evaluate())
-
- records = [
- record
- for record in caplog.records
- if record.name == "privacy_guard.service.servicer"
- and record.getMessage().startswith("privacy_guard_evaluation ")
- ]
- assert len(records) == 1
- assert getattr(records[0], "request_id") == request_id
- assert (
- r'request_id="trusted\u0020action=allow\u0020error_code=\"none\""'
- in records[0].getMessage()
- )
- assert records[0].getMessage().count(" action=") == 1
-
-
-def test_middleware_applies_configured_timeout_to_active_processor() -> None:
- middleware = PrivacyGuardMiddleware(
- create_builtin_registry(),
- timeout_seconds=4.5,
- )
- try:
- processor = middleware._policy.processor_for(_values())
- finally:
- asyncio.run(middleware.close())
-
- assert processor._timeout_seconds == 4.5
-
-
-def test_evaluation_decodes_one_utf8_text_and_encodes_replacement() -> None:
- async def evaluate() -> pb2.HttpRequestResult:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- try:
- return await middleware._evaluate_http_request(_request(b"email a@b.com"))
- finally:
- await middleware.close()
-
- result = asyncio.run(evaluate())
-
- assert result.decision == pb2.DECISION_ALLOW
- assert result.has_body is True
- assert result.body == b"email [email]"
- assert len(result.findings) == 1
- assert result.findings[0].type == "detected_entity"
- assert result.findings[0].label == "email (regex[1])"
-
-
-def test_evaluation_prepares_configuration_off_the_event_loop(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- event_loop_thread = get_ident()
- preparation_threads: list[int] = []
- original_processor_for = servicer_module._ActivePolicy.processor_for
-
- def record_preparation(
- policy: servicer_module._ActivePolicy,
- values: object,
- ) -> RequestProcessor:
- preparation_threads.append(get_ident())
- return original_processor_for(policy, values)
-
- monkeypatch.setattr(
- servicer_module._ActivePolicy,
- "processor_for",
- record_preparation,
- )
-
- async def evaluate() -> None:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- try:
- await middleware._evaluate_http_request(_request(b"email a@b.com"))
- finally:
- await middleware.close()
-
- asyncio.run(evaluate())
-
- assert len(preparation_threads) == 1
- assert preparation_threads[0] != event_loop_thread
-
-
-def test_evaluation_revalidates_configuration_before_reusing_active_processor(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- validation_count = 0
- original_validate = servicer_module.EngineRegistry.validate_config
-
- def record_validation(
- registry: servicer_module.EngineRegistry,
- values: object,
- ) -> PrivacyGuardConfig[EngineConfig]:
- nonlocal validation_count
- validation_count += 1
- return original_validate(registry, values)
-
- monkeypatch.setattr(
- servicer_module.EngineRegistry,
- "validate_config",
- record_validation,
- )
-
- async def evaluate_twice() -> None:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- try:
- request = _request(b"email a@b.com")
- await middleware._evaluate_http_request(request)
- await middleware._evaluate_http_request(request)
- finally:
- await middleware.close()
-
- asyncio.run(evaluate_twice())
-
- assert validation_count == 2
-
-
-def test_active_policy_reuses_only_the_current_configuration() -> None:
- policy = servicer_module._ActivePolicy(
- create_builtin_registry(),
- timeout_seconds=1,
- log_request_content=False,
- )
- first_values = _values(action="detect")
- second_values = _values(action="block")
-
- first = policy.processor_for(first_values)
- same = policy.processor_for(deepcopy(first_values))
- second = policy.processor_for(second_values)
- rebuilt_first = policy.processor_for(first_values)
-
- assert same is first
- assert second is not first
- assert rebuilt_first is not first
- assert rebuilt_first is not second
-
-
-@pytest.mark.parametrize("initial_action", [None, "detect"])
-def test_concurrent_requests_for_the_same_policy_build_once(
- monkeypatch: pytest.MonkeyPatch,
- initial_action: str | None,
-) -> None:
- worker_count = 4
- workers_ready = Barrier(worker_count)
- build_started = Event()
- release_build = Event()
- build_count = 0
- build_count_lock = Lock()
- original_build = servicer_module._ActivePolicy._build_processor
- policy = servicer_module._ActivePolicy(
- create_builtin_registry(),
- timeout_seconds=1,
- log_request_content=False,
- )
- initial = (
- policy.processor_for(_values(action=initial_action))
- if initial_action is not None
- else None
- )
- requested_values = _values(
- action="block" if initial_action is not None else "detect"
- )
-
- def pause_build(
- active_policy: servicer_module._ActivePolicy,
- config: PrivacyGuardConfig[EngineConfig],
- ) -> RequestProcessor:
- nonlocal build_count
- with build_count_lock:
- build_count += 1
- build_started.set()
- assert release_build.wait(timeout=5)
- return original_build(active_policy, config)
-
- monkeypatch.setattr(
- servicer_module._ActivePolicy,
- "_build_processor",
- pause_build,
- )
-
- def resolve_policy() -> RequestProcessor:
- workers_ready.wait(timeout=5)
- return policy.processor_for(requested_values)
-
- with ThreadPoolExecutor(max_workers=worker_count) as executor:
- futures = tuple(executor.submit(resolve_policy) for _ in range(worker_count))
- assert build_started.wait(timeout=5)
- assert all(not future.done() for future in futures)
- release_build.set()
- processors = tuple(future.result(timeout=5) for future in futures)
-
- assert build_count == 1
- assert all(processor is processors[0] for processor in processors)
- assert processors[0] is not initial
- assert policy.processor_for(requested_values) is processors[0]
-
-
-def test_different_policy_updates_are_serialized(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- first_build_started = Event()
- release_first_build = Event()
- second_build_started = Event()
- build_actions: list[str] = []
- active_builds = 0
- maximum_active_builds = 0
- build_count_lock = Lock()
- original_build = servicer_module._ActivePolicy._build_processor
- policy = servicer_module._ActivePolicy(
- create_builtin_registry(),
- timeout_seconds=1,
- log_request_content=False,
- )
- initial = policy.processor_for(_values(action="detect"))
-
- def control_build(
- active_policy: servicer_module._ActivePolicy,
- config: PrivacyGuardConfig[EngineConfig],
- ) -> RequestProcessor:
- nonlocal active_builds, maximum_active_builds
- action = config.on_detection.action.value
- with build_count_lock:
- active_builds += 1
- maximum_active_builds = max(maximum_active_builds, active_builds)
- build_actions.append(action)
- try:
- if action == "block":
- first_build_started.set()
- assert release_first_build.wait(timeout=5)
- elif action == "replace":
- second_build_started.set()
- return original_build(active_policy, config)
- finally:
- with build_count_lock:
- active_builds -= 1
-
- monkeypatch.setattr(
- servicer_module._ActivePolicy,
- "_build_processor",
- control_build,
- )
-
- with ThreadPoolExecutor(max_workers=2) as executor:
- first_update = executor.submit(policy.processor_for, _values(action="block"))
- assert first_build_started.wait(timeout=5)
- second_update = executor.submit(
- policy.processor_for,
- _values(action="replace"),
- )
- assert not second_build_started.wait(timeout=0.1)
- release_first_build.set()
- first_processor = first_update.result(timeout=5)
- second_processor = second_update.result(timeout=5)
-
- assert second_build_started.is_set()
- assert build_actions == ["block", "replace"]
- assert maximum_active_builds == 1
- assert first_processor is not initial
- assert second_processor is not first_processor
- assert policy.processor_for(_values(action="replace")) is second_processor
-
-
-def test_failed_policy_update_preserves_the_active_processor(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- failure = PrivacyGuardError(ErrorCode.UNEXPECTED_SERVICE_FAILURE)
- original_build = servicer_module._ActivePolicy._build_processor
- policy = servicer_module._ActivePolicy(
- create_builtin_registry(),
- timeout_seconds=1,
- log_request_content=False,
- )
- active_values = _values(action="detect")
- update_values = _values(action="block")
- active = policy.processor_for(active_values)
-
- def fail_update(
- active_policy: servicer_module._ActivePolicy,
- config: PrivacyGuardConfig[EngineConfig],
- ) -> RequestProcessor:
- if config.on_detection.action.value == "block":
- raise failure
- return original_build(active_policy, config)
-
- monkeypatch.setattr(
- servicer_module._ActivePolicy,
- "_build_processor",
- fail_update,
- )
-
- with pytest.raises(PrivacyGuardError) as captured:
- policy.processor_for(update_values)
-
- assert captured.value is failure
- assert policy.processor_for(active_values) is active
-
- monkeypatch.setattr(
- servicer_module._ActivePolicy,
- "_build_processor",
- original_build,
- )
- updated = policy.processor_for(update_values)
-
- assert updated is not active
- assert policy.processor_for(update_values) is updated
-
-
-def test_compiled_cache_eviction_does_not_invalidate_active_processor(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- regex_module._clear_compiled_pattern_cache()
- registry = create_builtin_registry()
- policy = servicer_module._ActivePolicy(
- registry,
- timeout_seconds=1,
- log_request_content=False,
- )
- processor_values = _values(
- "detect",
- rules=[{"pattern": "aaa", "confidence": "high"}],
- )
- validation_values = _values(
- "detect",
- rules=[{"pattern": "bbb", "confidence": "high"}],
- )
-
- try:
- processor = policy.processor_for(processor_values)
- entry_weight = regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES
- monkeypatch.setattr(
- regex_module,
- "MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES",
- entry_weight,
- )
-
- registry.validate_config(validation_values)
-
- result = processor.process("aaa")
- assert len(result.detection_summaries) == 1
- assert policy.processor_for(processor_values) is processor
- assert regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES <= entry_weight
- finally:
- policy.clear()
- regex_module._clear_compiled_pattern_cache()
-
-
-def test_middleware_shutdown_clears_active_policy() -> None:
- regex_module._clear_compiled_pattern_cache()
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- try:
- middleware._policy.processor_for(_values("detect"))
-
- asyncio.run(middleware.close())
-
- assert middleware._policy._config is None
- assert middleware._policy._processor is None
- finally:
- middleware._policy.clear()
- regex_module._clear_compiled_pattern_cache()
-
-
-def test_oversized_stage_list_fails_before_engine_construction(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- values = _values(action="detect", stage_count=10_000)
-
- def unexpected_call(*args: object, **kwargs: object) -> object:
- del args, kwargs
- raise AssertionError("oversized stage list reached preparation")
-
- monkeypatch.setattr(
- servicer_module.EngineRegistry,
- "create_engine",
- unexpected_call,
- )
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- try:
- with pytest.raises(PrivacyGuardError) as captured:
- middleware._policy.processor_for(values)
- finally:
- asyncio.run(middleware.close())
-
- assert captured.value.code is ErrorCode.CONFIG_INVALID
-
-
-def test_invalid_utf8_fails_before_invoking_an_engine() -> None:
- async def evaluate() -> None:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- try:
- with pytest.raises(PrivacyGuardError) as captured:
- await middleware._evaluate_http_request(_request(b"\xff"))
- assert captured.value.code is ErrorCode.BODY_ENCODING_INVALID
- finally:
- await middleware.close()
-
- asyncio.run(evaluate())
-
-
-def test_detect_returns_no_body_mutation() -> None:
- async def evaluate() -> pb2.HttpRequestResult:
- middleware = PrivacyGuardMiddleware(create_builtin_registry())
- try:
- return await middleware._evaluate_http_request(
- _request(b"a@b.com", action="detect")
- )
- finally:
- await middleware.close()
-
- result = asyncio.run(evaluate())
-
- assert result.decision == pb2.DECISION_ALLOW
- assert result.has_body is False
- assert result.body == b""
diff --git a/projects/privacy-guard/tests/test_cli.py b/projects/privacy-guard/tests/test_cli.py
deleted file mode 100644
index b48ba022..00000000
--- a/projects/privacy-guard/tests/test_cli.py
+++ /dev/null
@@ -1,429 +0,0 @@
-"""Privacy Guard command-line application tests."""
-
-from __future__ import annotations
-
-import json
-import re
-from collections.abc import Iterator
-from importlib.metadata import entry_points
-from pathlib import Path
-from types import SimpleNamespace
-
-import pytest
-from typer.testing import CliRunner, Result
-
-from privacy_guard import cli as cli_module
-from privacy_guard.cli import app
-from privacy_guard.engines.registry import EngineRegistry, create_builtin_registry
-from privacy_guard.errors import ErrorCode, PrivacyGuardError
-from privacy_guard.logging import reset_logging
-from privacy_guard.service.server import PrivacyGuardServer
-
-
-@pytest.fixture(autouse=True)
-def _reset_cli_logging() -> Iterator[None]:
- yield
- reset_logging()
-
-
-def test_cli_help_exposes_server_and_discovery_commands() -> None:
- result = CliRunner().invoke(app, ["--help"])
-
- assert result.exit_code == 0
- output = _plain_output(result)
- assert "serve" in output
- assert "configuration-schema" in output
- assert "add-gateway-registration" in output
- assert "remove-gateway-registration" in output
- assert "engines" in output
- assert "--debug" in output
- assert "--debug-log-content" in output
- assert "--registry-factory" in output
- assert "--config" not in output
- assert "--profile" not in output
- assert "--scanner-name" not in output
-
-
-def test_cli_add_gateway_registration_help_requires_an_explicit_host_ip() -> None:
- result = CliRunner().invoke(
- app,
- ["add-gateway-registration", "--help"],
- terminal_width=240,
- )
-
- assert result.exit_code == 0
- output = _normalized_output(result)
- assert "--host-ip" in output
- assert "required" in output.lower()
- assert "Non-loopback IPv4" in output
- assert "$OPENSHELL_GATEWAY_CONFIG" in output
- assert "$XDG_CONFIG_HOME/openshell" in output
- assert "1-128 ASCII bytes" in output
- assert "restart the OpenShell gateway" not in output
-
-
-def test_cli_add_gateway_registration_updates_the_default_xdg_config(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-) -> None:
- monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
-
- result = CliRunner().invoke(
- app,
- ["add-gateway-registration", "--host-ip", "192.168.1.20"],
- )
-
- assert result.exit_code == 0
- config_path = tmp_path / "openshell" / "gateway.toml"
- assert config_path.exists()
- output = _plain_output(result)
- assert f"Created {config_path}" in output
- assert "Registered privacy-guard at http://192.168.1.20:50051" in output
- assert "start Privacy Guard, then restart the OpenShell gateway" in output
-
-
-@pytest.mark.parametrize("host_ip", ["host.openshell.internal", "127.0.0.1", "0.0.0.0"])
-def test_cli_add_gateway_registration_rejects_unusable_host_ip(host_ip: str) -> None:
- result = CliRunner().invoke(
- app,
- ["add-gateway-registration", "--host-ip", host_ip],
- terminal_width=240,
- )
-
- assert result.exit_code == 2
- output = _normalized_output(result)
- assert "--host-ip" in output
- assert "IPv4 address" in output
-
-
-@pytest.mark.parametrize(
- "name",
- [
- "a" * 129,
- "privacy guard",
- "openshell/privacy-guard",
- ],
-)
-def test_cli_add_gateway_registration_rejects_invalid_registration_name(
- name: str,
-) -> None:
- result = CliRunner().invoke(
- app,
- [
- "add-gateway-registration",
- "--host-ip",
- "192.168.1.20",
- "--name",
- name,
- ],
- terminal_width=240,
- )
-
- assert result.exit_code == 2
- assert "--name" in _normalized_output(result)
-
-
-def test_cli_add_gateway_registration_reports_invalid_existing_config(
- tmp_path: Path,
-) -> None:
- path = tmp_path / "gateway.toml"
- path.write_text("not valid TOML")
-
- result = CliRunner().invoke(
- app,
- [
- "add-gateway-registration",
- "--host-ip",
- "192.168.1.20",
- "--config",
- str(path),
- ],
- )
-
- assert result.exit_code == 1
- output = _plain_output(result)
- assert "Could not add or update the OpenShell gateway registration" in output
- assert "not valid TOML" in output
- assert path.read_text() == "not valid TOML"
-
-
-def test_cli_remove_gateway_registration_removes_the_named_registration(
- tmp_path: Path,
-) -> None:
- path = tmp_path / "gateway.toml"
- path.write_text(
- "[openshell]\n"
- "version = 1\n\n"
- "[[openshell.supervisor.middleware]]\n"
- 'name = "privacy-guard-regex"\n'
- 'grpc_endpoint = "http://192.168.1.20:50051"\n'
- )
-
- result = CliRunner().invoke(
- app,
- [
- "remove-gateway-registration",
- "--name",
- "privacy-guard-regex",
- "--config",
- str(path),
- ],
- )
-
- assert result.exit_code == 0
- output = _plain_output(result)
- assert f"Removed privacy-guard-regex from {path}" in output
- assert "restart the OpenShell gateway" in output
- assert "privacy-guard-regex" not in path.read_text()
-
-
-def test_cli_remove_gateway_registration_requires_a_name() -> None:
- result = CliRunner().invoke(
- app,
- ["remove-gateway-registration"],
- terminal_width=240,
- )
-
- assert result.exit_code == 2
- output = _normalized_output(result)
- assert "--name" in output
- assert "missing option" in output.lower()
-
-
-def test_cli_remove_gateway_registration_reports_absent_name(
- tmp_path: Path,
-) -> None:
- path = tmp_path / "gateway.toml"
- path.write_text("[openshell]\nversion = 1\n")
-
- result = CliRunner().invoke(
- app,
- [
- "remove-gateway-registration",
- "--name",
- "privacy-guard-regex",
- "--config",
- str(path),
- ],
- )
-
- assert result.exit_code == 0
- assert (
- f"No registration named privacy-guard-regex found in {path}"
- in _plain_output(result)
- )
-
-
-def test_console_script_targets_the_cli_module() -> None:
- console_script = next(
- entry_point
- for entry_point in entry_points(group="console_scripts")
- if entry_point.name == "privacy-guard"
- )
-
- assert console_script.value == "privacy_guard.cli:app"
-
-
-def test_cli_serve_help_explains_the_processing_timeout() -> None:
- result = CliRunner().invoke(app, ["serve", "--help"])
-
- assert result.exit_code == 0
- output = _normalized_output(result)
- assert "--timeout-seconds" in output
- assert "shared by all processing stages" in output
- assert "at most 30" in output
-
-
-def test_cli_engines_describes_the_installed_engine() -> None:
- result = CliRunner().invoke(app, ["engines"])
-
- assert result.exit_code == 0
- assert result.output.startswith("regex\tdetect,replace\t")
- description = (
- "Detect every regex match, including matches that share input characters"
- )
- assert description in result.output
-
-
-def test_cli_configuration_schema_prints_finalized_policy_schema() -> None:
- result = CliRunner().invoke(app, ["configuration-schema"])
-
- assert result.exit_code == 0
- schema = json.loads(result.output)
- serialized = json.dumps(schema, sort_keys=True)
- assert '"propertyName": "engine"' in serialized
- assert '"regex"' in serialized
- assert '"on_detection"' in serialized
-
-
-def test_cli_loads_one_finalized_operator_registry(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- registry = create_builtin_registry()
- factory_calls = 0
-
- def create_registry() -> EngineRegistry:
- nonlocal factory_calls
- factory_calls += 1
- return registry
-
- monkeypatch.setattr(
- cli_module.importlib,
- "import_module",
- lambda module_name: (
- SimpleNamespace(create_registry=create_registry)
- if module_name == "operator_engines"
- else None
- ),
- )
-
- result = CliRunner().invoke(
- app,
- ["--registry-factory", "operator_engines:create_registry", "engines"],
- )
-
- assert result.exit_code == 0
- assert factory_calls == 1
- assert result.output.startswith("regex\tdetect,replace\t")
-
-
-@pytest.mark.parametrize(
- ("factory_reference", "reason"),
- [
- ("missing-separator", "my_engines:create_registry"),
- ("operator_engines:missing", "Verify the module:factory reference"),
- ("operator_engines:not_callable", "Export a callable"),
- ("operator_engines:failed", "Run the factory directly"),
- ("operator_engines:wrong_type", "Return an EngineRegistry"),
- ("operator_engines:unfinished", "Call finalize()"),
- ],
-)
-def test_cli_rejects_invalid_registry_factories(
- monkeypatch: pytest.MonkeyPatch,
- factory_reference: str,
- reason: str,
-) -> None:
- def fail() -> EngineRegistry:
- raise RuntimeError("sensitive factory failure")
-
- module = SimpleNamespace(
- not_callable=object(),
- failed=fail,
- wrong_type=lambda: object(),
- unfinished=lambda: EngineRegistry(),
- )
- monkeypatch.setattr(
- cli_module.importlib,
- "import_module",
- lambda _: module,
- )
-
- result = CliRunner().invoke(
- app,
- ["--registry-factory", factory_reference, "engines"],
- terminal_width=240,
- )
-
- assert result.exit_code == 2
- assert reason in _normalized_output(result)
- assert "sensitive factory failure" not in _plain_output(result)
-
-
-def test_cli_explains_registry_module_import_failures_without_leaking_details(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- def fail_import(_: str) -> object:
- raise RuntimeError("sensitive import failure")
-
- monkeypatch.setattr(cli_module.importlib, "import_module", fail_import)
-
- result = CliRunner().invoke(
- app,
- ["--registry-factory", "operator_engines:create_registry", "engines"],
- terminal_width=240,
- )
-
- assert result.exit_code == 2
- output = _normalized_output(result)
- assert "Registry module could not be imported" in output
- assert "import the module directly with content-safe diagnostics" in output
- assert "sensitive import failure" not in _plain_output(result)
-
-
-def test_cli_serve_adapts_operational_options_to_the_programmatic_server(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- calls: list[tuple[str, float, bool]] = []
-
- def record_serve_sync(self: PrivacyGuardServer, listen: str) -> None:
- calls.append(
- (
- listen,
- self._middleware._policy._timeout_seconds,
- self._middleware._policy._log_request_content,
- )
- )
-
- monkeypatch.setattr(PrivacyGuardServer, "serve_sync", record_serve_sync)
-
- result = CliRunner().invoke(
- app,
- [
- "--debug-log-content",
- "serve",
- "--listen",
- "127.0.0.1:50052",
- "--timeout-seconds",
- "4.5",
- ],
- )
-
- assert result.exit_code == 0
- assert calls == [("127.0.0.1:50052", 4.5, True)]
- assert "privacy_guard_request_content_logging_enabled" in _plain_output(result)
-
-
-def test_cli_serve_prints_cataloged_startup_errors_without_a_traceback(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- def fail_safely(_: PrivacyGuardServer, listen: str) -> None:
- del listen
- raise PrivacyGuardError(ErrorCode.SERVER_BIND_FAILED)
-
- monkeypatch.setattr(PrivacyGuardServer, "serve_sync", fail_safely)
-
- result = CliRunner().invoke(
- app,
- ["serve", "--listen", "sensitive-listen-address"],
- )
-
- assert result.exit_code == 1
- assert "[server_bind_failed]" in result.output
- assert "Choose an available listen address and port, then retry" in result.output
- assert "sensitive-listen-address" not in result.output
- assert "Traceback" not in result.output
-
-
-@pytest.mark.parametrize("timeout_seconds", ["0", "31", "nan"])
-def test_cli_rejects_invalid_processing_timeout(timeout_seconds: str) -> None:
- result = CliRunner().invoke(
- app,
- ["serve", "--timeout-seconds", timeout_seconds],
- terminal_width=240,
- )
-
- assert result.exit_code == 2
- output = _normalized_output(result)
- assert "--timeout-seconds" in output
- assert "greater than 0 and at most 30" in output
-
-
-def _normalized_output(result: Result) -> str:
- return " ".join(_plain_output(result).replace("│", " ").split())
-
-
-def _plain_output(result: Result) -> str:
- return _ANSI_STYLE_PATTERN.sub("", result.output)
-
-
-_ANSI_STYLE_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
diff --git a/projects/privacy-guard/tests/test_config.py b/projects/privacy-guard/tests/test_config.py
deleted file mode 100644
index 58b936da..00000000
--- a/projects/privacy-guard/tests/test_config.py
+++ /dev/null
@@ -1,558 +0,0 @@
-from __future__ import annotations
-
-import os
-import subprocess
-import sys
-from collections.abc import Callable
-from copy import deepcopy
-from pathlib import Path
-
-import pytest
-import yaml
-from pydantic import ValidationError
-
-import privacy_guard.engines.regex as regex_module
-from privacy_guard.config import (
- PolicyAction,
-)
-from privacy_guard.engines import (
- RegexEngine,
- RegexEngineConfig,
- RegexPatternCatalog,
-)
-from privacy_guard.engines.registry import EngineRegistry
-from privacy_guard.errors import ErrorCode, PrivacyGuardError
-
-
-def _registry() -> EngineRegistry:
- registry = EngineRegistry()
- registry.register(RegexEngine)
- registry.finalize()
- return registry
-
-
-def _config(
- *,
- action: str = "detect",
- replacement: dict[str, object] | None = None,
- stage_name: str | None = None,
-):
- engine_config = {
- "engine": "regex",
- "pattern_catalog": {
- "entities": [
- {
- "name": "email",
- "rules": [
- {
- "pattern": r"\buser@example\.com\b",
- "confidence": "high",
- }
- ],
- }
- ]
- },
- }
- if replacement is not None:
- engine_config["replacement"] = replacement
- stage = {"config": engine_config}
- if stage_name is not None:
- stage["name"] = stage_name
- return {
- "entity_processing": {"stages": [stage]},
- "on_detection": {"action": action},
- }
-
-
-@pytest.mark.parametrize("action", list(PolicyAction))
-def test_policy_action_uses_detect_block_replace(action: PolicyAction) -> None:
- replacement: dict[str, object] | None = (
- {"strategy": "template", "template": "[{entity}]"}
- if action is PolicyAction.REPLACE
- else None
- )
- config = _registry().validate_config(
- _config(action=action.value, replacement=replacement)
- )
-
- assert config.on_detection.action is action
- assert [item.value for item in PolicyAction] == ["detect", "block", "replace"]
-
-
-def test_known_discriminator_constructs_the_exact_engine_config() -> None:
- config = _registry().validate_config(_config())
- stage = config.entity_processing.stages[0]
-
- assert type(stage.config) is RegexEngineConfig
- assert type(stage.config.pattern_catalog) is RegexPatternCatalog
- assert stage.config.pattern_catalog.entities[0].rules[0].name is None
- assert stage.diagnostic_name(1) == "regex[1]"
-
-
-def test_policy_accepts_ten_stages_and_rejects_eleven() -> None:
- registry = _registry()
- exact = _config()
- stage = deepcopy(exact["entity_processing"]["stages"][0])
- exact["entity_processing"]["stages"] = [deepcopy(stage) for _ in range(10)]
- oversized = deepcopy(exact)
- oversized["entity_processing"]["stages"].append(deepcopy(stage))
-
- parsed = registry.validate_config(exact)
-
- assert len(parsed.entity_processing.stages) == 10
- with pytest.raises(PrivacyGuardError) as captured:
- registry.validate_config(oversized)
- assert captured.value.code is ErrorCode.CONFIG_INVALID
-
-
-def test_explicit_stage_name_is_the_diagnostic_source() -> None:
- config = _registry().validate_config(_config(stage_name="credentials"))
-
- assert config.entity_processing.stages[0].diagnostic_name(1) == "credentials"
-
-
-def test_discriminated_union_round_trip_preserves_concrete_fields() -> None:
- registry = _registry()
- parsed = registry.validate_config(
- _config(
- action="replace",
- replacement={"strategy": "template", "template": "[{entity}]"},
- )
- )
- serialized = parsed.model_dump(mode="json")
- reparsed = registry.validate_config(serialized)
-
- assert type(reparsed.entity_processing.stages[0].config) is RegexEngineConfig
- assert reparsed == parsed
- assert serialized["entity_processing"]["stages"][0]["config"]["engine"] == "regex"
- assert (
- serialized["entity_processing"]["stages"][0]["config"]["replacement"][
- "strategy"
- ]
- == "template"
- )
-
-
-def test_generated_schema_declares_the_engine_discriminator() -> None:
- schema = _registry().configuration_json_schema()
- definitions = _required_dict(schema, "$defs")
- stage_definition = next(
- definition
- for name, definition in definitions.items()
- if isinstance(name, str) and name.startswith("EntityProcessingStage")
- )
- properties = _required_dict(stage_definition, "properties")
- config_schema = _required_dict(properties, "config")
-
- assert config_schema["discriminator"] == {
- "mapping": {"regex": "#/$defs/RegexEngineConfig"},
- "propertyName": "engine",
- }
-
-
-def _required_dict(mapping: object, key: str):
- assert isinstance(mapping, dict)
- value = mapping.get(key)
- assert isinstance(value, dict)
- return value
-
-
-def test_catalog_file_and_inline_catalog_produce_the_same_config(
- tmp_path: Path,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- registry = _registry()
- inline_values = _config()
- file_values = deepcopy(inline_values)
- inline_catalog = file_values["entity_processing"]["stages"][0]["config"][
- "pattern_catalog"
- ]
- (tmp_path / "patterns.yaml").write_text(
- yaml.safe_dump(inline_catalog),
- encoding="utf-8",
- )
- file_values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = (
- "patterns.yaml"
- )
- monkeypatch.chdir(tmp_path)
-
- inline_config = registry.validate_config(inline_values)
- file_config = registry.validate_config(file_values)
-
- assert file_config == inline_config
- serialized_catalog = file_config.model_dump(mode="json")["entity_processing"][
- "stages"
- ][0]["config"]["pattern_catalog"]
- inline_serialized_catalog = inline_config.model_dump(mode="json")[
- "entity_processing"
- ]["stages"][0]["config"]["pattern_catalog"]
- assert serialized_catalog == inline_serialized_catalog
- assert isinstance(serialized_catalog, dict)
-
-
-def test_catalog_file_change_produces_a_different_validated_config(
- tmp_path: Path,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- catalog_path = tmp_path / "patterns.yaml"
- values = _config()
- catalog = values["entity_processing"]["stages"][0]["config"]["pattern_catalog"]
- catalog_path.write_text(yaml.safe_dump(catalog), encoding="utf-8")
- values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = (
- "patterns.yaml"
- )
- monkeypatch.chdir(tmp_path)
- registry = _registry()
- first = registry.validate_config(values)
-
- catalog["entities"][0]["rules"][0]["confidence"] = "low"
- catalog_path.write_text(yaml.safe_dump(catalog), encoding="utf-8")
- second = registry.validate_config(values)
-
- assert first != second
-
-
-def test_equivalent_catalogs_reuse_compiled_regex_rules(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- regex_module._clear_compiled_pattern_cache()
- original_compile_rule = regex_module._compile_rule
- compile_calls = 0
-
- def record_compile_rule(
- entity: regex_module.RegexEntity,
- rule: regex_module.RegexRule,
- catalog_index: int,
- entity_rule_index: int,
- ) -> regex_module._CompiledRule:
- nonlocal compile_calls
- compile_calls += 1
- return original_compile_rule(
- entity,
- rule,
- catalog_index,
- entity_rule_index,
- )
-
- monkeypatch.setattr(regex_module, "_compile_rule", record_compile_rule)
- registry = _registry()
- first = registry.validate_config(_config())
- registry.create_engine(first.entity_processing.stages[0].config)
- second = registry.validate_config(deepcopy(_config()))
- registry.create_engine(second.entity_processing.stages[0].config)
- changed_values = deepcopy(_config())
- changed_values["entity_processing"]["stages"][0]["config"]["pattern_catalog"][
- "entities"
- ][0]["rules"][0]["pattern"] = "changed"
- changed = registry.validate_config(changed_values)
- registry.create_engine(changed.entity_processing.stages[0].config)
-
- assert compile_calls == 2
- regex_module._clear_compiled_pattern_cache()
-
-
-@pytest.mark.parametrize(
- "catalog_path",
- [
- "missing.yaml",
- "../patterns.yaml",
- "patterns.json",
- ],
-)
-def test_catalog_file_rejects_invalid_paths(
- tmp_path: Path,
- monkeypatch: pytest.MonkeyPatch,
- catalog_path: str,
-) -> None:
- values = _config()
- values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = catalog_path
- monkeypatch.chdir(tmp_path)
-
- with pytest.raises(PrivacyGuardError) as exception_info:
- _registry().validate_config(values)
-
- assert exception_info.value.code is ErrorCode.CONFIG_INVALID
-
-
-def test_catalog_file_rejects_absolute_paths(
- tmp_path: Path,
-) -> None:
- values = _config()
- values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = str(
- tmp_path / "patterns.yaml"
- )
-
- with pytest.raises(PrivacyGuardError) as exception_info:
- _registry().validate_config(values)
-
- assert exception_info.value.code is ErrorCode.CONFIG_INVALID
-
-
-def test_catalog_file_rejects_symlinks(
- tmp_path: Path,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- target = tmp_path / "target.yaml"
- target.write_text("entities: []\n", encoding="utf-8")
- (tmp_path / "patterns.yaml").symlink_to(target)
- values = _config()
- values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = (
- "patterns.yaml"
- )
- monkeypatch.chdir(tmp_path)
-
- with pytest.raises(PrivacyGuardError) as exception_info:
- _registry().validate_config(values)
-
- assert exception_info.value.code is ErrorCode.CONFIG_INVALID
-
-
-def test_catalog_file_rejects_a_symlink_swap_during_open(
- tmp_path: Path,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- catalog_root = tmp_path / "catalog-root"
- catalog_root.mkdir()
- catalog_path = catalog_root / "patterns.yaml"
- values = _config()
- catalog = values["entity_processing"]["stages"][0]["config"]["pattern_catalog"]
- catalog_path.write_text(yaml.safe_dump(catalog), encoding="utf-8")
- outside_path = tmp_path / "outside.yaml"
- outside_path.write_text(yaml.safe_dump(catalog), encoding="utf-8")
- values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = (
- "patterns.yaml"
- )
- monkeypatch.chdir(catalog_root)
-
- original_open = os.open
- swapped = False
-
- def swap_before_final_open(
- path: str | bytes | Path,
- flags: int,
- mode: int = 0o777,
- *,
- dir_fd: int | None = None,
- ) -> int:
- nonlocal swapped
- if path == "patterns.yaml" and dir_fd is not None and not swapped:
- swapped = True
- catalog_path.unlink()
- catalog_path.symlink_to(outside_path)
- return original_open(path, flags, mode, dir_fd=dir_fd)
-
- monkeypatch.setattr(regex_module.os, "open", swap_before_final_open)
-
- with pytest.raises(PrivacyGuardError) as exception_info:
- _registry().validate_config(values)
-
- assert swapped is True
- assert exception_info.value.code is ErrorCode.CONFIG_INVALID
-
-
-def test_catalog_file_rejects_a_fifo_without_blocking(tmp_path: Path) -> None:
- os.mkfifo(tmp_path / "patterns.yaml")
- probe = """
-from privacy_guard.engines.regex import _load_pattern_catalog_file
-
-try:
- _load_pattern_catalog_file("patterns.yaml")
-except ValueError:
- pass
-else:
- raise AssertionError("FIFO catalog was accepted")
-"""
-
- subprocess.run(
- [sys.executable, "-c", probe],
- cwd=tmp_path,
- check=True,
- timeout=5,
- )
-
-
-@pytest.mark.parametrize(
- "contents",
- [
- "entities:\n - name: first\n name: duplicate\n rules: []\n",
- (
- "entities:\n"
- " - &shared\n"
- " name: first\n"
- " rules:\n"
- " - pattern: x\n"
- " confidence: high\n"
- " - *shared\n"
- ),
- "entities: !!python/object/apply:builtins.list []\n",
- ],
-)
-def test_catalog_file_rejects_unsafe_yaml(
- tmp_path: Path,
- monkeypatch: pytest.MonkeyPatch,
- contents: str,
-) -> None:
- (tmp_path / "patterns.yaml").write_text(contents, encoding="utf-8")
- values = _config()
- values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = (
- "patterns.yaml"
- )
- monkeypatch.chdir(tmp_path)
-
- with pytest.raises(PrivacyGuardError) as exception_info:
- _registry().validate_config(values)
-
- assert exception_info.value.code is ErrorCode.CONFIG_INVALID
-
-
-def test_catalog_file_rejects_invalid_utf8(
- tmp_path: Path,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- (tmp_path / "patterns.yaml").write_bytes(b"\xff")
- values = _config()
- values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = (
- "patterns.yaml"
- )
- monkeypatch.chdir(tmp_path)
-
- with pytest.raises(PrivacyGuardError) as exception_info:
- _registry().validate_config(values)
-
- assert exception_info.value.code is ErrorCode.CONFIG_INVALID
-
-
-def test_catalog_file_rejects_oversized_content(
- tmp_path: Path,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- monkeypatch.setattr(regex_module, "MAX_REGEX_CATALOG_FILE_BYTES", 1)
- (tmp_path / "patterns.yaml").write_text("entities: []\n", encoding="utf-8")
- values = _config()
- values["entity_processing"]["stages"][0]["config"]["pattern_catalog"] = (
- "patterns.yaml"
- )
- monkeypatch.chdir(tmp_path)
-
- with pytest.raises(PrivacyGuardError) as exception_info:
- _registry().validate_config(values)
-
- assert exception_info.value.code is ErrorCode.CONFIG_INVALID
-
-
-def test_replace_requires_a_replacement_recipe_on_every_stage() -> None:
- with pytest.raises(PrivacyGuardError) as exception_info:
- _registry().validate_config(_config(action="replace"))
-
- assert exception_info.value.code is ErrorCode.CONFIG_INVALID
-
-
-@pytest.mark.parametrize("action", ["detect", "block"])
-def test_dormant_replacement_recipe_is_valid_for_detection_only_actions(
- action: str,
-) -> None:
- config = _registry().validate_config(
- _config(
- action=action,
- replacement={"strategy": "template", "template": "[redacted]"},
- )
- )
- engine_config = config.entity_processing.stages[0].config
-
- assert isinstance(engine_config, RegexEngineConfig)
- assert engine_config.replacement is not None
-
-
-@pytest.mark.parametrize(
- "mutation",
- [
- lambda values: values.update({"body_format": "json"}),
- lambda values: values.update({"on_finding": {"action": "observe"}}),
- lambda values: values["on_detection"].update({"action": "observe"}),
- lambda values: values["on_detection"].update({"action": "redact"}),
- lambda values: values["entity_processing"]["stages"][0]["config"].update(
- {"kind": "regex"}
- ),
- lambda values: values["entity_processing"]["stages"][0]["config"].update(
- {"preset": "pii"}
- ),
- ],
-)
-def test_removed_or_unknown_policy_fields_are_rejected(
- mutation: Callable[[dict[str, object]], None],
-) -> None:
- values = _config()
- mutation(values)
-
- with pytest.raises(PrivacyGuardError):
- _registry().validate_config(values)
-
-
-def test_stage_list_is_non_empty_and_explicit_names_are_unique() -> None:
- empty = _config()
- empty["entity_processing"]["stages"] = []
- duplicate = _config(stage_name="same")
- duplicate["entity_processing"]["stages"].append(
- deepcopy(duplicate["entity_processing"]["stages"][0])
- )
-
- with pytest.raises(PrivacyGuardError):
- _registry().validate_config(empty)
- with pytest.raises(PrivacyGuardError):
- _registry().validate_config(duplicate)
-
-
-def test_explicit_stage_name_cannot_collide_with_a_derived_name() -> None:
- values = _config(stage_name="regex[2]")
- values["entity_processing"]["stages"].append(
- deepcopy(_config()["entity_processing"]["stages"][0])
- )
-
- with pytest.raises(PrivacyGuardError):
- _registry().validate_config(values)
-
-
-def test_regex_rule_names_are_optional_but_supplied_names_are_unique() -> None:
- values = _config()
- rules = values["entity_processing"]["stages"][0]["config"]["pattern_catalog"][
- "entities"
- ][0]["rules"]
- rules.extend(
- [
- {"pattern": "second", "confidence": "low"},
- {"name": "named", "pattern": "third", "confidence": "medium"},
- ]
- )
- config = _registry().validate_config(values)
-
- regex_config = config.entity_processing.stages[0].config
- assert isinstance(regex_config, RegexEngineConfig)
- parsed_rules = regex_config.pattern_catalog.entities[0].rules
- assert [rule.name for rule in parsed_rules] == [None, None, "named"]
-
- rules.append({"name": "named", "pattern": "duplicate", "confidence": "high"})
- with pytest.raises(PrivacyGuardError):
- _registry().validate_config(values)
-
-
-def test_validated_config_equality_covers_concrete_expanded_config() -> None:
- registry = _registry()
- first = registry.validate_config(_config())
- equivalent = registry.validate_config(deepcopy(_config()))
- changed_values = _config()
- changed_values["entity_processing"]["stages"][0]["config"]["pattern_catalog"][
- "entities"
- ][0]["rules"][0]["confidence"] = "low"
- changed = registry.validate_config(changed_values)
-
- assert first == equivalent
- assert first != changed
-
-
-def test_models_are_frozen_and_hide_engine_configuration_from_repr() -> None:
- config = _registry().validate_config(_config())
- pattern = "sensitive-pattern-value"
-
- with pytest.raises(ValidationError):
- setattr(config.on_detection, "action", PolicyAction.BLOCK)
- assert pattern not in repr(config)
diff --git a/projects/privacy-guard/tests/test_errors.py b/projects/privacy-guard/tests/test_errors.py
deleted file mode 100644
index e38e0eda..00000000
--- a/projects/privacy-guard/tests/test_errors.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import inspect
-
-from privacy_guard.errors import (
- ErrorCode,
- ErrorComponent,
- ErrorKind,
- PrivacyGuardError,
-)
-
-
-def test_every_error_code_has_one_safe_complete_specification() -> None:
- sentinel = "sensitive-request-value-8472"
-
- assert len({code.value for code in ErrorCode}) == len(ErrorCode)
- for code in ErrorCode:
- error = PrivacyGuardError(code)
- message = str(error)
-
- assert f"[{code.value}]" in message
- assert error.component.value in message
- assert error.operation in message
- assert error.summary in message
- assert error.hint in message
- assert sentinel not in message
- assert repr(error) == f"PrivacyGuardError({message!r})"
-
-
-def test_error_kinds_distinguish_invalid_input_from_internal_failures() -> None:
- assert PrivacyGuardError(ErrorCode.CONFIG_INVALID).kind is ErrorKind.INVALID_INPUT
- assert (
- PrivacyGuardError(ErrorCode.ENGINE_EXECUTION_FAILED).kind is ErrorKind.INTERNAL
- )
- assert (
- PrivacyGuardError(ErrorCode.CONFIG_INVALID).component is ErrorComponent.CONFIG
- )
-
-
-def test_config_error_explains_the_transport_size_limit() -> None:
- error = PrivacyGuardError(ErrorCode.CONFIG_INVALID)
-
- assert "encoded configuration at or below 64 KiB" in error.hint
-
-
-def test_privacy_guard_error_exposes_only_a_catalog_code_parameter() -> None:
- assert list(inspect.signature(PrivacyGuardError).parameters) == ["code"]
diff --git a/projects/privacy-guard/tests/test_request_processor.py b/projects/privacy-guard/tests/test_request_processor.py
deleted file mode 100644
index be5a22cf..00000000
--- a/projects/privacy-guard/tests/test_request_processor.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""RequestProcessor tests for the one-text, ordered-stage contract."""
-
-from __future__ import annotations
-
-import logging
-from concurrent.futures import ThreadPoolExecutor
-from time import monotonic
-
-import pytest
-
-from privacy_guard.config import PolicyAction
-from privacy_guard.constants import MAX_BODY_BYTES
-from privacy_guard.engines import RegexEngine
-from privacy_guard.engines.registry import EngineRegistry
-from privacy_guard.errors import (
- EngineConfigurationError,
- EngineLimitExceededError,
- ErrorCode,
- PrivacyGuardError,
-)
-from privacy_guard.request_processor import RequestDecision, RequestProcessor
-from privacy_guard.string_validators import validate_scalar_string
-from privacy_guard.timeout import Timeout
-
-
-def _values(
- action: PolicyAction,
- *,
- include_second_stage: bool = True,
-) -> dict[str, object]:
- stages: list[dict[str, object]] = [
- {
- "name": "people",
- "config": {
- "engine": "regex",
- "pattern_catalog": {
- "entities": [
- {
- "name": "person",
- "rules": [
- {
- "pattern": "Alice",
- "confidence": "high",
- }
- ],
- }
- ]
- },
- "replacement": {
- "strategy": "template",
- "template": "[{entity}]",
- },
- },
- }
- ]
- if include_second_stage:
- stages.append(
- {
- "config": {
- "engine": "regex",
- "pattern_catalog": {
- "entities": [
- {
- "name": "marker",
- "rules": [
- {
- "pattern": "person",
- "confidence": "medium",
- }
- ],
- }
- ]
- },
- "replacement": {
- "strategy": "template",
- "template": "<{entity}>",
- },
- },
- }
- )
- return {
- "entity_processing": {"stages": stages},
- "on_detection": {"action": action.value},
- }
-
-
-def _processor(
- action: PolicyAction,
- *,
- include_second_stage: bool = True,
-) -> RequestProcessor:
- registry = EngineRegistry()
- registry.register(RegexEngine)
- registry.finalize()
- config = registry.validate_config(
- _values(action, include_second_stage=include_second_stage)
- )
- stages = tuple(
- (
- stage.diagnostic_name(index),
- registry.create_engine(stage.config),
- )
- for index, stage in enumerate(config.entity_processing.stages, start=1)
- )
- return RequestProcessor(config, stages)
-
-
-def test_replace_runs_stages_sequentially_over_the_current_text() -> None:
- result = _processor(PolicyAction.REPLACE).process("Hello Alice")
-
- assert result.decision is RequestDecision.ALLOW
- assert result.replacement_text == "Hello []"
- assert tuple(
- (item.entity, item.source_stage, item.count)
- for item in result.detection_summaries
- ) == (
- ("person", "people", 1),
- ("marker", "regex[2]", 1),
- )
-
-
-def test_detect_reports_without_returning_replacement_text() -> None:
- result = _processor(PolicyAction.DETECT).process("Hello Alice")
-
- assert result.decision is RequestDecision.ALLOW
- assert result.replacement_text is None
- assert tuple(item.entity for item in result.detection_summaries) == ("person",)
-
-
-def test_block_is_a_processor_disposition_not_an_engine_strategy() -> None:
- result = _processor(PolicyAction.BLOCK).process("Hello Alice")
-
- assert result.decision is RequestDecision.DENY
- assert result.replacement_text is None
- assert result.reason_code == "privacy_guard_blocked"
- assert tuple(item.entity for item in result.detection_summaries) == ("person",)
-
-
-def test_scalar_validation_rejects_lone_surrogates() -> None:
- with pytest.raises(ValueError, match="Unicode scalar"):
- validate_scalar_string("\ud800")
-
-
-def test_processor_accepts_exact_body_limit_and_rejects_one_byte_more() -> None:
- processor = _processor(PolicyAction.DETECT, include_second_stage=False)
-
- exact = processor.process("x" * MAX_BODY_BYTES)
-
- assert exact.decision is RequestDecision.ALLOW
- with pytest.raises(PrivacyGuardError) as captured:
- processor.process("x" * (MAX_BODY_BYTES + 1))
- assert captured.value.code is ErrorCode.REQUEST_BODY_TOO_LARGE
-
-
-def test_exact_limit_requests_complete_concurrently() -> None:
- processor = _processor(PolicyAction.DETECT, include_second_stage=False)
- text = "x" * MAX_BODY_BYTES
-
- with ThreadPoolExecutor(max_workers=4) as executor:
- results = tuple(executor.map(processor.process, (text,) * 4))
-
- assert all(result.decision is RequestDecision.ALLOW for result in results)
-
-
-def test_exact_limit_request_completes_with_multiple_stages() -> None:
- result = _processor(PolicyAction.DETECT).process("x" * MAX_BODY_BYTES)
-
- assert result.decision is RequestDecision.ALLOW
-
-
-def test_timeout_returns_the_bounded_limit_deny(
- monkeypatch: pytest.MonkeyPatch,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- monkeypatch.setattr(
- Timeout,
- "from_seconds",
- classmethod(lambda cls, seconds: cls(deadline=monotonic() - 1)),
- )
-
- with caplog.at_level(logging.INFO, logger="privacy_guard.request_processor"):
- result = _processor(PolicyAction.DETECT).process("Hello Alice")
-
- assert result.decision is RequestDecision.DENY
- assert result.reason_code == "privacy_guard_limit_exceeded"
- assert "privacy_guard_processing_limit kind=timeout" in caplog.text
- assert "Alice" not in caplog.text
-
-
-def test_engine_resource_limit_returns_the_bounded_limit_deny(
- monkeypatch: pytest.MonkeyPatch,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- def exceed_limit(*_: object, **__: object) -> object:
- raise EngineLimitExceededError("sensitive resource detail")
-
- monkeypatch.setattr(RegexEngine, "_run", exceed_limit)
-
- with caplog.at_level(logging.INFO, logger="privacy_guard.request_processor"):
- result = _processor(PolicyAction.DETECT).process("Hello Alice")
-
- assert result.decision is RequestDecision.DENY
- assert result.reason_code == "privacy_guard_limit_exceeded"
- assert "privacy_guard_processing_limit kind=resource" in caplog.text
- assert "Alice" not in caplog.text
- assert "sensitive resource detail" not in caplog.text
-
-
-def test_engine_configuration_failure_maps_to_invalid_config(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- def reject_config(*_: object, **__: object) -> object:
- raise EngineConfigurationError("sensitive configuration detail")
-
- monkeypatch.setattr(RegexEngine, "_run", reject_config)
-
- with pytest.raises(PrivacyGuardError) as captured:
- _processor(PolicyAction.DETECT).process("Hello Alice")
-
- assert captured.value.code is ErrorCode.CONFIG_INVALID
- assert "sensitive configuration detail" not in str(captured.value)
diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh
index 70457c29..18c0662a 100755
--- a/scripts/build-docs.sh
+++ b/scripts/build-docs.sh
@@ -30,9 +30,11 @@ fi
python -m pip install --upgrade pip
python -m pip install -r requirements-docs.txt
-python scripts/stage-privacy-guard-docs.py
+python scripts/stage-egress-gate-docs.py
python scripts/render-dev-notes.py
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/scripts/stage-privacy-guard-docs.py b/scripts/stage-egress-gate-docs.py
similarity index 78%
rename from scripts/stage-privacy-guard-docs.py
rename to scripts/stage-egress-gate-docs.py
index bf277bae..ba73bd7f 100644
--- a/scripts/stage-privacy-guard-docs.py
+++ b/scripts/stage-egress-gate-docs.py
@@ -2,7 +2,7 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
-"""Stage canonical Privacy Guard documentation in the site source tree."""
+"""Stage canonical Egress Gate documentation in the site source tree."""
from __future__ import annotations
@@ -12,11 +12,11 @@
ROOT = Path(__file__).resolve().parents[1]
-DEFAULT_SOURCE = ROOT / "projects" / "privacy-guard" / "docs"
-DEFAULT_DESTINATION = ROOT / "docs" / "documentation" / "privacy-guard"
+DEFAULT_SOURCE = ROOT / "projects" / "egress-gate" / "docs"
+DEFAULT_DESTINATION = ROOT / "docs" / "documentation" / "egress-gate"
-def stage_privacy_guard_docs(source: Path, destination: Path) -> None:
+def stage_egress_gate_docs(source: Path, destination: Path) -> None:
"""Replace the generated site mirror with one canonical project-docs tree."""
source = source.resolve()
@@ -46,8 +46,8 @@ def stage_privacy_guard_docs(source: Path, destination: Path) -> None:
def main() -> int:
- stage_privacy_guard_docs(DEFAULT_SOURCE, DEFAULT_DESTINATION)
- print(f"Staged Privacy Guard documentation from {DEFAULT_SOURCE}.")
+ stage_egress_gate_docs(DEFAULT_SOURCE, DEFAULT_DESTINATION)
+ print(f"Staged Egress Gate documentation from {DEFAULT_SOURCE}.")
return 0
diff --git a/tests/navigation-drawer.test.js b/tests/navigation-drawer.test.js
new file mode 100644
index 00000000..d018b976
--- /dev/null
+++ b/tests/navigation-drawer.test.js
@@ -0,0 +1,287 @@
+// 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, 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);
+
+ 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..2649c026
--- /dev/null
+++ b/tests/test_page_navigation.py
@@ -0,0 +1,63 @@
+# 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_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")
+
+ 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/tests/test_stage_privacy_guard_docs.py b/tests/test_stage_egress_gate_docs.py
similarity index 84%
rename from tests/test_stage_privacy_guard_docs.py
rename to tests/test_stage_egress_gate_docs.py
index 0230e9cb..418e0ce6 100644
--- a/tests/test_stage_privacy_guard_docs.py
+++ b/tests/test_stage_egress_gate_docs.py
@@ -10,16 +10,16 @@
ROOT = Path(__file__).resolve().parents[1]
-SCRIPT = ROOT / "scripts" / "stage-privacy-guard-docs.py"
+SCRIPT = ROOT / "scripts" / "stage-egress-gate-docs.py"
-SPEC = importlib.util.spec_from_file_location("stage_privacy_guard_docs", SCRIPT)
+SPEC = importlib.util.spec_from_file_location("stage_egress_gate_docs", SCRIPT)
if SPEC is None or SPEC.loader is None:
raise RuntimeError(f"could not load {SCRIPT}")
STAGER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(STAGER)
-class StagePrivacyGuardDocsTests(unittest.TestCase):
+class StageEgressGateDocsTests(unittest.TestCase):
def test_stage_replaces_destination_with_source_tree(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
@@ -34,7 +34,7 @@ def test_stage_replaces_destination_with_source_tree(self) -> None:
destination.mkdir()
(destination / "stale.md").write_text("# Stale\n", encoding="utf-8")
- STAGER.stage_privacy_guard_docs(source, destination)
+ STAGER.stage_egress_gate_docs(source, destination)
self.assertEqual(
(destination / "index.md").read_text(encoding="utf-8"),
@@ -53,7 +53,7 @@ def test_stage_rejects_symlinks_in_source(self) -> None:
(source / "linked.md").symlink_to(target)
with self.assertRaisesRegex(ValueError, "must not contain symlinks"):
- STAGER.stage_privacy_guard_docs(source, root / "site-docs")
+ STAGER.stage_egress_gate_docs(source, root / "site-docs")
def test_stage_rejects_destination_inside_source(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
@@ -61,7 +61,7 @@ def test_stage_rejects_destination_inside_source(self) -> None:
source.mkdir()
with self.assertRaisesRegex(ValueError, "must not overlap"):
- STAGER.stage_privacy_guard_docs(source, source / "published")
+ STAGER.stage_egress_gate_docs(source, source / "published")
def test_stage_rejects_source_inside_destination(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
@@ -70,7 +70,7 @@ def test_stage_rejects_source_inside_destination(self) -> None:
source.mkdir(parents=True)
with self.assertRaisesRegex(ValueError, "must not overlap"):
- STAGER.stage_privacy_guard_docs(source, destination)
+ STAGER.stage_egress_gate_docs(source, destination)
if __name__ == "__main__":
diff --git a/zensical.toml b/zensical.toml
index 41e1b6b0..d9c7e499 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -25,24 +25,25 @@ nav = [
]},
{"Documentation" = [
"documentation/index.md",
- {"Privacy Guard" = [
- "documentation/privacy-guard/index.md",
+ {"Egress Gate" = [
+ "documentation/egress-gate/index.md",
{"Guides" = [
- {"Configure policies" = "documentation/privacy-guard/configuration.md"},
- {"Run and operate Privacy Guard" = "documentation/privacy-guard/operations.md"}
+ {"Configure policies" = "documentation/egress-gate/configuration.md"},
+ {"Test policies offline" = "documentation/egress-gate/evaluation.md"},
+ {"Run and operate Egress Gate" = "documentation/egress-gate/operations.md"}
]},
- {"Engines" = [
- "documentation/privacy-guard/engines/index.md",
- {"RegexEngine" = "documentation/privacy-guard/engines/regex.md"},
- {"Add a custom engine" = "documentation/privacy-guard/engines/custom.md"}
+ {"Gates" = [
+ "documentation/egress-gate/gates/index.md",
+ {"Regex gate" = "documentation/egress-gate/gates/regex.md"},
+ {"Add a custom gate" = "documentation/egress-gate/gates/custom.md"}
]},
{"Architecture" = [
- "documentation/privacy-guard/architecture/index.md",
- {"Request lifecycle" = "documentation/privacy-guard/architecture/request-lifecycle.md"},
- {"Service boundary" = "documentation/privacy-guard/architecture/service-boundary.md"}
+ "documentation/egress-gate/architecture/index.md",
+ {"Request lifecycle" = "documentation/egress-gate/architecture/request-lifecycle.md"},
+ {"Service boundary" = "documentation/egress-gate/architecture/service-boundary.md"}
]},
{"Reference" = [
- {"Limits and failure behavior" = "documentation/privacy-guard/reference/limits-and-failures.md"}
+ {"Limits and failure behavior" = "documentation/egress-gate/reference/limits-and-failures.md"}
]}
]}
]}
@@ -54,15 +55,26 @@ 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",
+ "navigation.footer",
"navigation.top",
"search.highlight",
"toc.follow"