diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f012d..6382015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Phase 9.6 — `sovereign-sdk-airlock` outbound governance boundary** (new workspace + member `packages/sovereign-airlock/`): Introduces the model-neutral Airlock + lifecycle boundary that inspects, evaluates, minimises, and records structured + payloads before they cross a sovereign perimeter (SAR-0004). + + - **`NormalizedPayload` frozen dataclass** (`payload.py`): Provider-neutral + inspection surface extracted from any transport-specific request format. + Factory functions `normalize_openai()`, `normalize_anthropic()`, and + `normalize_raw()` translate protocol-specific schemas into the stable governance + surface without leaking transport state. + + - **`AirlockTelemetry` frozen dataclass** (`telemetry.py`): Four Component C sieve + metrics (`raw_tokens`, `sieved_tokens`, `tax_savings_percentage`, `payload_hash`). + `from_sieve_output()` classmethod includes an explicit `raw_tokens == 0` guard + defaulting `tax_savings_percentage` to `0.0`, preventing ZeroDivisionError on + zero-token or null-pass payloads. + + - **`PolicyEngine(config_path)`** (`policy.py`): Deterministic, fully offline YAML + rule evaluator supporting `raw`, `fields`, and `telemetry` evaluation scopes with + `allow`, `warn`, and `deny` actions. Global `max_token_ceiling` and + `prose_tax_warning_threshold` configuration. `AirlockConfigurationError` raised + on malformed, structurally invalid, or regex-invalid configuration. All regex + patterns pre-compiled via `re.compile()` at init time; malformed patterns raise + `AirlockConfigurationError` immediately rather than deferring to a runtime + `re.error`. `_VALID_METRICS` frozenset (`raw_tokens`, `sieved_tokens`, + `tax_savings_percentage`) validated in `_parse_rule` at boot time; unrecognised + metric names (e.g. typo `raw_token`) raise `AirlockConfigurationError` immediately. + `prose_tax_warning_threshold` validated to `[0.0, 1.0]` in `__init__`; values + outside this range raise `AirlockConfigurationError`. `PolicyRule.fields` stored + as `tuple[str, ...]` (immutable; previously `list[str]`). `_POST_SIEVE_METRICS` + frozenset (`sieved_tokens`, `tax_savings_percentage`) guards `_evaluate_telemetry`: + when `telemetry=None`, post-sieve metrics are skipped rather than proxied via + `payload.token_estimate`. `evaluate_post_sieve(telemetry)` evaluates post-sieve-only + telemetry rules and the prose tax threshold; returns a `PolicyVerdict` that may carry + `deny` violations. + + - **`NormalizedPayload` deep immutability** (`payload.py`): `__post_init__` converts + `content` to `tuple[str, ...]`, `metadata` to `types.MappingProxyType[str, Any]`, + and each `tools` entry to `types.MappingProxyType[str, Any]`. Constructor still + accepts mutable `list`/`dict` equivalents; conversion is transparent to all + factory functions. + + - **`ReceiptBuilder(key_manager, ledger)`** (`receipt.py`): Assembles boundary + crossing metadata and produces a signed `ForensicReceipt` via + `SovereignKeyManager.generate_receipt()`. Ledger write failure is non-fatal — + a `WARNING` log is emitted and the receipt is returned regardless, so outbound + transmission is never blocked by a storage-tier anomaly. + + - **`AirlockBoundary(policy_path, signing_key, ledger)`** (`boundary.py`): + Async orchestrator implementing the four-component transaction lifecycle. `deny` + verdict raises `AirlockPolicyViolation` before any sieve or ledger operation. + Post-sieve evaluation via `PolicyEngine.evaluate_post_sieve(telemetry)`: deny verdicts + raise `AirlockPolicyViolation`; warn verdicts are appended to `verdict.warnings` and + sealed in receipt metadata. + + - **76-case test suite** across `test_policy.py` (29), `test_telemetry.py` (12), + `test_receipts.py` (10), and `test_boundary.py` (25). Round 1 PR remediation adds + 7 cases: `test_raises_on_malformed_regex_pattern` (`TestPolicyLoading`), + `TestProseTaxThreshold` (2 cases), and `TestNormalizedPayloadImmutability` (4 cases). + Round 3 PR remediation adds 7 cases: `test_sieved_tokens_rule_skipped_when_telemetry_absent`, + `test_tax_savings_rule_skipped_when_telemetry_absent`, `test_evaluate_post_sieve_fires_sieved_tokens_warn_rule`, + `test_evaluate_post_sieve_deny_rule_returns_violation`, `test_policy_rule_fields_is_immutable_tuple`, + `test_negative_savings_clamped_to_zero`, and `test_post_sieve_telemetry_deny_raises_policy_violation`. + Round 4 PR remediation adds 3 cases: `test_raises_on_unknown_telemetry_metric`, + `test_raises_on_out_of_bounds_prose_tax_threshold`, and + `test_over_optimized_savings_clamped_to_hundred`. + **76 passed, 0 failed (airlock); 486 passed, 1 skipped (workspace).** + + - **`AirlockTelemetry.tax_savings_percentage` full clamp** (`telemetry.py`): + `max(0.0, min(100.0, round(...)))` applied to the savings calculation in + `from_sieve_output()`. Content expansion (sieved > raw) clamps to `0.0`; impossible + inversion (negative `optimized_token_count`) clamps to `100.0`. + - **Phase 9.5 — `sovereign-edge` sensor ingestion bridge** (new workspace member `packages/sovereign-edge/`): Introduces the middleware pipeline that intercepts sealed sensor wire frames from `sovereign-sensor`, applies the `sovereign-sieve` diff --git a/CLAUDE.md b/CLAUDE.md index 4af6ee9..c0f55a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,3 +58,17 @@ Before declaring an engineering task or phase update complete, you must execute - `commit-message.txt` generation: At the conclusion of a successful test execution pass, generate a pristine, technical Git commit message following Conventional Commits (e.g., `feat(ledger): implement hash-chained append-only storage engine`). Write this text directly into `commit-message.txt` at the root directory. Do not execute the git commit command yourself. - `uv` Workspace Compliance: After altering dependencies or adding new workspace members, always run `uv lock` to keep the monorepo's dependency layout completely verified and synchronized. + + +## Architectural Governance & Spec Invariants (SAR) + +All code generations, component implementations, and workspace modifications must strictly conform to the Sovereign Systems Specification Architecture Records (SARs) located under `architecture/receipts/`. + +### Core Technical Constraints: +1. **SAR-0004 (Containment Boundaries):** Components sitting at perimeter intersections (e.g., `sovereign-airlock`) must act as strict, deliberate inspection and containment zones—never transparent or routing proxies. +2. **SAR-0007 (Zero External Network Footprint):** Third-party runtime dependencies must be lightweight, non-ML, and non-transitive. Execution must be locked entirely to local machine silicon with zero cloud-mediated API requirements for validation. +3. **SAR-0009 (Fault-Tolerant Logging):** Ledger writes, signature logging, and evidence commits are explicitly non-fatal to outbound communication lifecycles. Storage-tier faults must emit a warning locally and fail open safely to prevent crashing agent runtimes. +4. **SAR-0010 (Vocabulary Mapping):** All exposed classes, errors, objects, and types must mapped strictly from terms declared in the established Specification Glossary (`AirlockBoundary`, `NormalizedPayload`, etc.). + +### Pre-Flight Verification Rule: +Before initiating code modifications for any core module, the model must read `planning_docs/active_plans` for active blueprints, verify its execution loop against the relevant governing SAR files, and trace the directional dependency graph (upstream packages must remain entirely isolated from downstream orchestrators). \ No newline at end of file diff --git a/README.md b/README.md index 15e1dad..897d7cf 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,21 @@ This repository is managed as an integrated `uv` workspace separating the crypto │ │ └── tests/ │ │ └── test_edge.py │ │ +│ ├── sovereign-airlock/ # Outbound governance boundary (SAR-0004) +│ │ ├── src/sovereign_airlock/ +│ │ │ ├── boundary.py # AirlockBoundary — async 4-component orchestrator +│ │ │ ├── payload.py # NormalizedPayload — provider-neutral inspection surface +│ │ │ ├── policy.py # PolicyEngine — YAML rule evaluation (raw/fields/telemetry) +│ │ │ ├── telemetry.py # AirlockTelemetry — sieve convergence metrics +│ │ │ ├── receipt.py # ReceiptBuilder — evidence assembly and ledger commit +│ │ │ ├── exception.py # AirlockPolicyViolation, AirlockConfigurationError +│ │ │ └── __init__.py +│ │ └── tests/ +│ │ ├── test_boundary.py +│ │ ├── test_policy.py +│ │ ├── test_receipts.py +│ │ └── test_telemetry.py +│ │ │ ├── sovereign-runtime/ # Compute/Execution tier (tool & model isolation) │ │ └── src/sovereign_runtime/ │ │ ├── router.py # Intent-based pre-flight namespace exposure @@ -215,6 +230,44 @@ Three fortification properties are enforced at the architecture level: --- +## `sovereign-sdk-airlock` — Outbound Governance Boundary + +For applications and agentic runtimes that must govern what leaves the sovereign perimeter, +`sovereign-sdk-airlock` provides the deliberate inspection and containment boundary defined +by SAR-0004 (Airlock, Not Gateway): + +```python +import asyncio +from sovereign_ledger import SovereignLedger +from sovereign_airlock import AirlockBoundary, AirlockPolicyViolation, normalize_openai + +ledger = SovereignLedger(".keys/sovereign_audit.db") +boundary = AirlockBoundary( + policy_path="policy.yaml", + signing_key=".keys/", + ledger=ledger, +) + +try: + result = await boundary.process(normalize_openai(request)) + # result.sieved_content — minimised payload ready for transmission + # result.telemetry.payload_hash — SHA-256 of the raw pre-sieve content + # result.receipt["signature"] — Ed25519 boundary crossing evidence + # result.policy_warnings — non-fatal warn-rule messages +except AirlockPolicyViolation as exc: + # Payload blocked by a deny rule — do not transmit + raise +``` + +Policy rules are declared in a local YAML file and evaluated in three scopes: +- `raw` — regex pattern matched against the flat combined content string +- `fields` — pattern matched against named structured fields (`messages.content`, `tools.description`, etc.) +- `telemetry` — numeric threshold applied to `raw_tokens`, `sieved_tokens`, or `tax_savings_percentage` + +Airlock is transport-agnostic. `normalize_openai()`, `normalize_anthropic()`, and `normalize_raw()` convert any request format into the provider-neutral `NormalizedPayload` before governance evaluation begins. + +--- + ## `sovereign-sensor` — Bare-Metal Write-Side Custody For IoT and embedded systems where data must be sealed cryptographically at the exact point of genesis — before any network hop or cloud ingestion — `sovereign-sensor` provides a MicroPython-compatible Hardware Abstraction Layer that runs on ESP32 and Raspberry Pi Pico with zero external dependencies: diff --git a/ROADMAP.md b/ROADMAP.md index 9d600ca..afca875 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1225,6 +1225,98 @@ committed = pipeline.drain_buffer() --- +## Phase 9.6 — Outbound Governance Boundary (`sovereign-sdk-airlock`) — Shipped ✓ + +**Target:** Introduce the model-neutral outbound governance boundary that inspects, +evaluates, minimises, and records structured payloads before they cross a sovereign +perimeter and enter an external computational system. Implements the four-component +Airlock lifecycle defined in SAR-0004 (Airlock, Not Gateway). + +```python +import asyncio +from sovereign_ledger import SovereignLedger +from sovereign_airlock import AirlockBoundary, normalize_openai + +ledger = SovereignLedger(".keys/sovereign_audit.db") +boundary = AirlockBoundary( + policy_path="policy.yaml", + signing_key=".keys/", + ledger=ledger, +) + +result = await boundary.process(normalize_openai(request)) +# result.sieved_content — Prose-Tax-minimised payload for transmission +# result.telemetry.payload_hash — SHA-256 of the raw pre-sieve content +# result.receipt["signature"] — Ed25519 boundary crossing evidence +# result.policy_warnings — non-fatal warn-rule messages +``` + +**Delivered:** + +* [x] `NormalizedPayload` frozen dataclass (`payload.py`) — provider-neutral inspection + surface with `source`, `content`, `metadata`, `tools`, and `token_estimate` fields; + factory functions `normalize_openai()`, `normalize_anthropic()`, and `normalize_raw()` + translate transport-specific request schemas to the common governance surface +* [x] `AirlockTelemetry` frozen dataclass (`telemetry.py`) — four Component C sieve + metrics (`raw_tokens`, `sieved_tokens`, `tax_savings_percentage`, `payload_hash`); + `from_sieve_output()` classmethod re-derives `tax_savings_percentage` independently + of `SieveOutput` with an explicit `raw_tokens == 0` guard and full + `max(0.0, min(100.0, ...))` clamp enforcing the documented `[0.0, 100.0]` range +* [x] `PolicyEngine(config_path)` (`policy.py`) — deterministic, offline YAML rule + evaluator supporting `raw` (regex over flat content), `fields` (dot-notation field + extraction), and `telemetry` (numeric metric threshold) scopes; actions `allow`, + `warn`, `deny`; global `max_token_ceiling` check; `AirlockConfigurationError` on + invalid YAML, unrecognised scope/action values, unrecognised telemetry metric names + (validated at boot against `_VALID_METRICS`), or `prose_tax_warning_threshold` outside + `[0.0, 1.0]`; `_POST_SIEVE_METRICS` frozenset gates pre-sieve evaluation so + `sieved_tokens` and `tax_savings_percentage` rules are never proxied against + `token_estimate`; `evaluate_post_sieve(telemetry)` evaluates those metrics against + actual sieve output and applies the prose tax threshold check, returning a + `PolicyVerdict` that can carry `deny` violations +* [x] `PolicyRule` and `PolicyVerdict` frozen and mutable dataclasses (`policy.py`) — + immutable rule descriptor (`fields` stored as `tuple[str, ...]`) and mutable evaluation + result carrying `allowed`, `violations`, and `warnings` lists +* [x] `ReceiptBuilder(key_manager, ledger)` (`receipt.py`) — assembles boundary + crossing metadata (`boundary`, `source_transport`, `prose_tax_summary`, + `policy_warnings`), signs via `SovereignKeyManager.generate_receipt()`, and commits + to `SovereignLedger.append_receipt()`; ledger write failure emits `WARNING`-level + log and returns the receipt regardless — outbound transmission is never blocked +* [x] `AirlockBoundary(policy_path, signing_key, ledger)` (`boundary.py`) — async + orchestrator implementing the four-component transaction lifecycle: policy evaluation + → sieve convergence → post-sieve telemetry evaluation → evidence generation; pre-sieve + `deny` verdict raises `AirlockPolicyViolation` immediately; post-sieve + `evaluate_post_sieve()` deny verdict also raises `AirlockPolicyViolation`; receipt + generation failure is non-fatal (logged, `receipt=None` in result) +* [x] `AirlockResult` dataclass (`boundary.py`) — structured result carrying + `sieved_content`, `telemetry`, `receipt`, and `policy_warnings` +* [x] `AirlockPolicyViolation(RuntimeError)` and `AirlockConfigurationError(ValueError)` + (`exception.py`) — domain exceptions for deny-action enforcement and configuration + invariant violations respectively +* [x] `packages/sovereign-airlock/pyproject.toml` — workspace member at version + `1.4.0`; runtime dependencies: `sovereign-sdk-core>=1.3.0`, + `sovereign-sdk-ledger>=1.3.0`, `sovereign-sdk-sieve>=1.3.0`, `pyyaml>=6.0` +* [x] 76-case test suite across four files (`TestAirlockTelemetry`: 12; + `TestPolicyLoading` + `TestRawScopeEvaluation` + `TestFieldsScopeEvaluation` + + `TestTelemetryScopeEvaluation` + `TestGlobalCeiling`: 29; `TestReceiptBuilder`: 10; + `TestAirlockBoundaryHappyPath` + `TestAirlockBoundaryPolicyDenial` + + `TestAirlockBoundaryPolicyWarning` + `TestAirlockBoundaryTransportNeutrality` + + `TestAirlockBoundaryResiliency` + `TestProseTaxThreshold` + + `TestNormalizedPayloadImmutability`: 25) covering frozen telemetry dataclass + immutability, zero-token ZeroDivisionError guard, full `[0.0, 100.0]` savings clamp, + payload hash derivation, YAML config loading, all three rule scopes, all three + policy actions, global ceiling enforcement, regex boot-time compilation with + `AirlockConfigurationError` on malformed patterns, boot-time telemetry metric name + validation, `prose_tax_warning_threshold` range validation `[0.0, 1.0]`, pre-sieve + metric skip guard, post-sieve `evaluate_post_sieve()` deny/warn evaluation, + `PolicyRule.fields` tuple immutability, prose tax threshold warning lifecycle, + deep payload immutability (`tuple` + `MappingProxyType`), receipt metadata invariants, + cryptographic verifiability, non-fatal ledger write failure, transport-neutral + normalisation (OpenAI, Anthropic, raw), deny/warn/allow lifecycle correctness, and + full async `AirlockBoundary.process()` transaction lifecycle end-to-end. + **76 passed, 0 failed (airlock); 486 passed, 1 skipped (workspace).** + +--- + ## Phase 10 — Isolated Context Vault & Governance Server (`sovereign-vault`) **Target:** Implement the "Sovereign Vault" architecture as an isolated local orchestration diff --git a/packages/sovereign-airlock/README.md b/packages/sovereign-airlock/README.md new file mode 100644 index 0000000..851c3d4 --- /dev/null +++ b/packages/sovereign-airlock/README.md @@ -0,0 +1,120 @@ +# sovereign-sdk-airlock + +Model-neutral outbound governance boundary for the Sovereign Systems SDK. + +Inspects, evaluates, minimises, and records structured payloads before they cross +a sovereign perimeter and enter an external computational system. + +--- + +## Architecture + +`sovereign-sdk-airlock` implements the four-component lifecycle defined in the +Airlock Specification (SAR-0004): + +| Component | Responsibility | +|---|---| +| **Boundary Interception** | Capture normalised outbound payloads; transport-agnostic | +| **Policy Engine** | Evaluate local YAML governance rules (`raw`, `fields`, `telemetry` scopes) | +| **Sieve Convergence** | Deterministic context minimisation via `sovereign-sdk-sieve` | +| **Evidence Generation** | Immutable `ForensicReceipt` committed to `sovereign-sdk-ledger` | + +--- + +## Quick Start + +```python +import asyncio +from sovereign_ledger import SovereignLedger +from sovereign_airlock import AirlockBoundary, normalize_openai + +ledger = SovereignLedger(".keys/sovereign_audit.db") +boundary = AirlockBoundary( + policy_path="policy.yaml", + signing_key=".keys/", + ledger=ledger, +) + +request = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Analyse the quarterly report."}], +} + +async def main(): + result = await boundary.process(normalize_openai(request)) + print(result.sieved_content) + print(result.telemetry.tax_savings_percentage, "% Prose Tax savings") + print(result.receipt["payload_hash"]) + +asyncio.run(main()) +``` + +--- + +## Policy Configuration + +```yaml +version: "1.0" + +global: + max_token_ceiling: 40000 + prose_tax_warning_threshold: 0.35 + +rules: + + - name: block_private_keys + scope: raw + pattern: "-----BEGIN PRIVATE KEY-----" + action: deny + + - name: guard_internal_namespaces + scope: fields + fields: + - messages.content + - tools.description + pattern: "internal\\.sovereign\\.local" + action: warn + + - name: excessive_context + scope: telemetry + metric: raw_tokens + threshold: 30000 + action: warn +``` + +### Policy Actions + +| Action | Behaviour | +|---|---| +| `allow` | Explicit pass-through — no effect on verdict | +| `warn` | Appends to `AirlockResult.policy_warnings`; transmission continues | +| `deny` | Raises `AirlockPolicyViolation`; payload blocked | + +--- + +## Transport Normalisation + +Airlock governs boundaries, not transports. Use the factory functions to convert +provider-specific request formats into the provider-neutral `NormalizedPayload`: + +```python +from sovereign_airlock import normalize_openai, normalize_anthropic, normalize_raw + +# OpenAI-compatible chat completion request +payload = normalize_openai({"messages": [...], "model": "gpt-4o"}) + +# Anthropic-compatible messages API request +payload = normalize_anthropic({"messages": [...], "system": "...", "model": "claude-sonnet-4-6"}) + +# Raw string +payload = normalize_raw("plain text to govern") +``` + +--- + +## Invariants + +- Zero external network dependencies — all operations execute on local silicon. +- Ledger write failure is non-fatal; outbound transmission is never blocked by a storage anomaly. +- Context minimisation uses `sovereign-sdk-sieve` exclusively — no model-based compression. +- All policy evaluation is deterministic and auditable. diff --git a/packages/sovereign-airlock/pyproject.toml b/packages/sovereign-airlock/pyproject.toml new file mode 100644 index 0000000..b4f66ad --- /dev/null +++ b/packages/sovereign-airlock/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=77.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "sovereign-sdk-airlock" +version = "1.4.0" +description = "Model-neutral outbound governance boundary for sovereign perimeter inspection, policy enforcement, and evidence generation" +readme = "README.md" +requires-python = ">=3.12" +license = { text = "MIT" } +dependencies = [ + "sovereign-sdk-core>=1.3.0", + "sovereign-sdk-ledger>=1.3.0", + "sovereign-sdk-sieve>=1.3.0", + "pyyaml>=6.0", +] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.12", + "Intended Audience :: Developers", + "Topic :: Security", + "Topic :: Software Development :: Libraries", +] + +[project.urls] +Homepage = "https://github.com/kenwalger/sovereign-sdk" +Repository = "https://github.com/kenwalger/sovereign-sdk" +Changelog = "https://github.com/kenwalger/sovereign-sdk/blob/main/CHANGELOG.md" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/packages/sovereign-airlock/src/sovereign_airlock/__init__.py b/packages/sovereign-airlock/src/sovereign_airlock/__init__.py new file mode 100644 index 0000000..975e81f --- /dev/null +++ b/packages/sovereign-airlock/src/sovereign_airlock/__init__.py @@ -0,0 +1,33 @@ +# packages/sovereign-airlock/src/sovereign_airlock/__init__.py +"""sovereign-sdk-airlock — Model-neutral outbound governance boundary. + +Inspects, evaluates, minimises, and records structured payloads before they cross +a sovereign perimeter and enter an external computational system. Implements the +four-component Airlock lifecycle: boundary interception, machine-local policy +enforcement, deterministic context minimisation, and immutable evidence generation. +""" + +from .boundary import AirlockBoundary, AirlockResult +from .exception import AirlockConfigurationError, AirlockPolicyViolation +from .payload import NormalizedPayload, normalize_anthropic, normalize_openai, normalize_raw +from .policy import PolicyEngine, PolicyRule, PolicyVerdict +from .receipt import ReceiptBuilder +from .telemetry import AirlockTelemetry + +__version__ = "1.4.0" + +__all__ = [ + "AirlockBoundary", + "AirlockConfigurationError", + "AirlockPolicyViolation", + "AirlockResult", + "AirlockTelemetry", + "NormalizedPayload", + "PolicyEngine", + "PolicyRule", + "PolicyVerdict", + "ReceiptBuilder", + "normalize_anthropic", + "normalize_openai", + "normalize_raw", +] diff --git a/packages/sovereign-airlock/src/sovereign_airlock/boundary.py b/packages/sovereign-airlock/src/sovereign_airlock/boundary.py new file mode 100644 index 0000000..7e025a1 --- /dev/null +++ b/packages/sovereign-airlock/src/sovereign_airlock/boundary.py @@ -0,0 +1,168 @@ +# packages/sovereign-airlock/src/sovereign_airlock/boundary.py +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from sovereign_core import ForensicReceipt, SovereignKeyManager +from sovereign_ledger import SovereignLedger +from sovereign_sieve import sieve_with_metrics + +from .exception import AirlockPolicyViolation +from .payload import NormalizedPayload +from .policy import PolicyEngine, PolicyVerdict +from .receipt import ReceiptBuilder +from .telemetry import AirlockTelemetry + +_logger = logging.getLogger(__name__) + + +@dataclass +class AirlockResult: + """Structured outcome of a successful Airlock boundary crossing evaluation. + + Returned by :meth:`AirlockBoundary.process` when the outbound payload is + permitted (i.e. no ``deny`` rule matched). Callers use this object to + obtain the sieved payload, governance telemetry, the sealed evidence receipt, + and any non-fatal policy warnings that were triggered during evaluation. + + :param sieved_content: The Prose-Tax-minimised string produced by the + :mod:`sovereign_sieve` convergence pass. This is the content that + was signed and committed to the ledger, and that should be used for + the actual outbound transmission. + :type sieved_content: str + :param telemetry: Sieve-pass metrics for this boundary event. + :type telemetry: AirlockTelemetry + :param receipt: The cryptographically signed :class:`~sovereign_core.ForensicReceipt` + produced for this crossing. ``None`` only when signing itself fails + (an unusual, non-ledger failure path). + :type receipt: ForensicReceipt | None + :param policy_warnings: Human-readable messages emitted by ``warn``-action rules + that matched during evaluation. Empty list when all rules were silent. + :type policy_warnings: list[str] + """ + + sieved_content: str + telemetry: AirlockTelemetry + receipt: ForensicReceipt | None + policy_warnings: list[str] = field(default_factory=list) + + +class AirlockBoundary: + """Outbound governance boundary implementing the four-component Airlock lifecycle. + + Orchestrates the transaction lifecycle defined in the Airlock specification: + + 1. **Policy evaluation** (Component B) — payload is inspected against local YAML rules. + 2. **Sieve convergence** (Component C) — context minimisation via :mod:`sovereign_sieve`. + 3. **Evidence generation** (Component D) — a signed :class:`~sovereign_core.ForensicReceipt` + is committed to the configured :class:`~sovereign_ledger.SovereignLedger`. + + Boundary traversal (step 4 — the actual outbound API call) is performed by the caller + after this class returns the :class:`AirlockResult`. Evidence recording is non-fatal: + a ledger write failure logs a local warning and returns the result regardless, so + outbound transmission is never blocked by a storage-tier anomaly. + + :param policy_path: Path to the YAML policy configuration file. + :type policy_path: str | Path + :param signing_key: Directory path for :class:`~sovereign_core.SovereignKeyManager` + keypair storage (same semantics as ``key_dir`` on that class). + :type signing_key: str | Path + :param ledger: Optional :class:`~sovereign_ledger.SovereignLedger` for durable + receipt persistence. When ``None``, receipts are signed but not stored. + :type ledger: SovereignLedger | None + :raises AirlockConfigurationError: If the policy YAML file is missing or malformed. + """ + + _policy: PolicyEngine + _receipt_builder: ReceiptBuilder + + def __init__( + self, + policy_path: str | Path, + signing_key: str | Path, + ledger: SovereignLedger | None = None, + ) -> None: + """Initialise the boundary with a policy engine, signing identity, and optional ledger. + + :param policy_path: Path to the YAML policy configuration file. + :type policy_path: str | Path + :param signing_key: Key directory for :class:`~sovereign_core.SovereignKeyManager`. + :type signing_key: str | Path + :param ledger: Optional ledger for Write-Side Custody evidence commit. + :type ledger: SovereignLedger | None + """ + self._policy = PolicyEngine(policy_path) + key_manager = SovereignKeyManager(key_dir=signing_key) + self._receipt_builder = ReceiptBuilder(key_manager=key_manager, ledger=ledger) + + async def process(self, payload: NormalizedPayload) -> AirlockResult: + """Execute the full Airlock governance lifecycle for a single outbound payload. + + Transaction lifecycle (per the Airlock specification sequence diagram): + + 1. Evaluate all policy rules against the normalised payload. A ``deny`` verdict + raises :exc:`~sovereign_airlock.exception.AirlockPolicyViolation` immediately + and no sieve or ledger operation is performed. + 2. Pass the combined content through :func:`~sovereign_sieve.sieve_with_metrics` + to produce the Prose-Tax-minimised sieved content and token metrics. + 3. Invoke :meth:`~sovereign_airlock.policy.PolicyEngine.evaluate_post_sieve` with + the sieve telemetry. Post-sieve ``sieved_tokens`` and ``tax_savings_percentage`` + telemetry rules are evaluated against actual values; a ``deny`` outcome raises + :exc:`~sovereign_airlock.exception.AirlockPolicyViolation`. The + ``prose_tax_warning_threshold`` check is also applied here; breaches are + appended as non-fatal warnings. + 4. Attempt evidence recording via :class:`~sovereign_airlock.receipt.ReceiptBuilder`. + Ledger write failure is non-fatal: a warning is emitted and the result is + returned with the signed receipt intact. Signing failure is logged and the + result is returned with ``receipt=None``. + + :param payload: The provider-neutral payload produced by one of the normalisation + factory functions in :mod:`~sovereign_airlock.payload`. + :type payload: NormalizedPayload + :return: A populated :class:`AirlockResult` carrying the sieved content, telemetry, + signed receipt, and any non-fatal policy warnings. + :rtype: AirlockResult + :raises AirlockPolicyViolation: If any configured ``deny`` rule matches the payload + or the global token ceiling is breached. + """ + # Component B: Policy evaluation (pre-sieve) + verdict: PolicyVerdict = self._policy.evaluate(payload) + if not verdict.allowed: + raise AirlockPolicyViolation("; ".join(verdict.violations)) + + # Component C: Sieve convergence pass + raw_content: str = " ".join(payload.content) + sieve_output = sieve_with_metrics(raw_content) + telemetry: AirlockTelemetry = AirlockTelemetry.from_sieve_output( + sieve_output, raw_content + ) + + # Post-sieve: evaluate telemetry rules requiring actual sieve output + post_verdict = self._policy.evaluate_post_sieve(telemetry) + if not post_verdict.allowed: + raise AirlockPolicyViolation("; ".join(post_verdict.violations)) + verdict.warnings.extend(post_verdict.warnings) + + # Component D: Evidence generation — non-fatal on any failure + receipt: ForensicReceipt | None = None + try: + receipt = self._receipt_builder.build_and_commit( + sieve_output.text, + telemetry, + verdict.warnings, + payload.source, + ) + except Exception as exc: + _logger.warning( + "Airlock evidence recording failed — payload_hash=%s error=%r", + telemetry.payload_hash, + exc, + ) + + return AirlockResult( + sieved_content=sieve_output.text, + telemetry=telemetry, + receipt=receipt, + policy_warnings=verdict.warnings, + ) diff --git a/packages/sovereign-airlock/src/sovereign_airlock/exception.py b/packages/sovereign-airlock/src/sovereign_airlock/exception.py new file mode 100644 index 0000000..860553d --- /dev/null +++ b/packages/sovereign-airlock/src/sovereign_airlock/exception.py @@ -0,0 +1,26 @@ +# packages/sovereign-airlock/src/sovereign_airlock/exception.py + + +class AirlockPolicyViolation(RuntimeError): + """Raised when an outbound payload triggers a ``deny`` action in the policy engine. + + Carrying the concatenated violation messages from all matching deny rules, + this exception signals that the payload has been blocked at the sovereign + perimeter and must not be transmitted to the external system. + + :param message: Human-readable description of the policy rules that were violated. + :type message: str + """ + + +class AirlockConfigurationError(ValueError): + """Raised when a policy YAML configuration file is missing, malformed, or structurally invalid. + + Extends :exc:`ValueError` to signal that the error is a configuration + invariant violation rather than a runtime data-quality issue. Callers + should treat this as a fatal initialisation failure requiring operator + intervention rather than a recoverable runtime exception. + + :param message: Human-readable description of the configuration defect. + :type message: str + """ diff --git a/packages/sovereign-airlock/src/sovereign_airlock/payload.py b/packages/sovereign-airlock/src/sovereign_airlock/payload.py new file mode 100644 index 0000000..3764274 --- /dev/null +++ b/packages/sovereign-airlock/src/sovereign_airlock/payload.py @@ -0,0 +1,181 @@ +# packages/sovereign-airlock/src/sovereign_airlock/payload.py +from __future__ import annotations + +import types +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class NormalizedPayload: + """Provider-neutral inspection object for outbound governance evaluation. + + A :class:`NormalizedPayload` is not a transport object. It is an inspection + object: a stable governance surface derived from any originating protocol, + provider, or runtime. All policy evaluation, telemetry generation, context + minimisation, and receipt creation operate exclusively against this structure. + + Constructor accepts mutable equivalents (``list[str]`` for ``content``, + ``dict[str, Any]`` for ``metadata``, ``list[dict[str, Any]]`` for ``tools``); + ``__post_init__`` converts each to its corresponding immutable form. + + :param source: Transport or provider identifier (e.g. ``"openai"``, ``"anthropic"``, ``"raw"``). + :type source: str + :param content: Ordered collection of text strings extracted from the payload (system + prompt, user messages, raw text, etc.). Stored as an immutable ``tuple[str, ...]``; + joined with a single space for flat-string evaluation in ``raw`` and ``fields`` scope rules. + :type content: tuple[str, ...] + :param metadata: Transport-specific fields that are not part of the primary content + surface (e.g. ``model``, ``temperature``, request headers). Stored as an immutable + :class:`~types.MappingProxyType` to prevent post-construction mutation. + :type metadata: types.MappingProxyType[str, Any] + :param tools: Ordered collection of tool/function definitions attached to the request, + each expressed as a provider-neutral read-only mapping with at minimum ``name`` and + ``description`` keys. Stored as an immutable ``tuple`` of :class:`~types.MappingProxyType` + entries. + :type tools: tuple[types.MappingProxyType[str, Any], ...] + :param token_estimate: Heuristic token count of the combined content, computed via the + UTF-8 byte-density heuristic (÷ 4). Used for pre-sieve policy telemetry evaluation. + :type token_estimate: int + """ + + source: str + content: tuple[str, ...] + metadata: types.MappingProxyType[str, Any] = field( + default_factory=lambda: types.MappingProxyType({}) + ) + tools: tuple[types.MappingProxyType[str, Any], ...] = field(default_factory=tuple) + token_estimate: int = 0 + + def __post_init__(self) -> None: + """Convert mutable constructor arguments to their immutable stored equivalents. + + :raises TypeError: If any ``tools`` entry cannot be coerced to a ``dict``. + """ + object.__setattr__(self, "content", tuple(self.content)) + object.__setattr__( + self, + "metadata", + self.metadata + if isinstance(self.metadata, types.MappingProxyType) + else types.MappingProxyType(dict(self.metadata)), + ) + object.__setattr__( + self, + "tools", + tuple( + t + if isinstance(t, types.MappingProxyType) + else types.MappingProxyType(dict(t)) + for t in self.tools + ), + ) + + +def _estimate_tokens(text: str) -> int: + """Approximates token count using the UTF-8 byte-density heuristic (÷ 4).""" + return max(0, len(text.encode("utf-8")) // 4) + + +def normalize_openai(request: dict[str, Any]) -> NormalizedPayload: + """Normalise an OpenAI-compatible chat completion request into a :class:`NormalizedPayload`. + + Extracts content from ``messages[].content`` (both plain string and multi-part + content-block formats), and tool definitions from the top-level ``tools`` key. + + :param request: A dict representing an OpenAI chat completion request body. + :type request: dict[str, Any] + :return: A provider-neutral :class:`NormalizedPayload` suitable for governance evaluation. + :rtype: NormalizedPayload + """ + content: list[str] = [] + for msg in request.get("messages") or []: + msg_content = msg.get("content", "") + if isinstance(msg_content, str): + content.append(msg_content) + elif isinstance(msg_content, list): + for part in msg_content: + if isinstance(part, dict) and part.get("type") == "text": + content.append(part.get("text", "")) + + tools: list[dict[str, Any]] = list(request.get("tools") or []) + metadata: dict[str, Any] = { + k: v for k, v in request.items() if k not in ("messages", "tools") + } + combined = " ".join(content) + return NormalizedPayload( + source="openai", + content=content, + metadata=metadata, + tools=tools, + token_estimate=_estimate_tokens(combined), + ) + + +def normalize_anthropic(request: dict[str, Any]) -> NormalizedPayload: + """Normalise an Anthropic-compatible messages API request into a :class:`NormalizedPayload`. + + Handles both plain-string and content-block ``messages[].content`` formats, and + the top-level ``system`` field (plain string or content-block list). + + :param request: A dict representing an Anthropic messages API request body. + :type request: dict[str, Any] + :return: A provider-neutral :class:`NormalizedPayload` suitable for governance evaluation. + :rtype: NormalizedPayload + """ + content: list[str] = [] + + system = request.get("system") + if isinstance(system, str): + content.append(system) + elif isinstance(system, list): + for block in system: + if isinstance(block, dict) and block.get("type") == "text": + content.append(block.get("text", "")) + + for msg in request.get("messages") or []: + msg_content = msg.get("content", "") + if isinstance(msg_content, str): + content.append(msg_content) + elif isinstance(msg_content, list): + for block in msg_content: + if isinstance(block, dict) and block.get("type") == "text": + content.append(block.get("text", "")) + + tools: list[dict[str, Any]] = list(request.get("tools") or []) + metadata: dict[str, Any] = { + k: v for k, v in request.items() if k not in ("messages", "tools", "system") + } + combined = " ".join(content) + return NormalizedPayload( + source="anthropic", + content=content, + metadata=metadata, + tools=tools, + token_estimate=_estimate_tokens(combined), + ) + + +def normalize_raw( + text: str, + source: str = "raw", + metadata: dict[str, Any] | None = None, +) -> NormalizedPayload: + """Normalise a plain string into a :class:`NormalizedPayload`. + + :param text: The raw string payload to wrap. + :type text: str + :param source: Transport identifier label. Defaults to ``"raw"``. + :type source: str + :param metadata: Optional annotation mapping attached to the payload. + :type metadata: dict[str, Any] | None + :return: A :class:`NormalizedPayload` wrapping the raw string. + :rtype: NormalizedPayload + """ + return NormalizedPayload( + source=source, + content=[text], + metadata=metadata or {}, + tools=[], + token_estimate=_estimate_tokens(text), + ) diff --git a/packages/sovereign-airlock/src/sovereign_airlock/policy.py b/packages/sovereign-airlock/src/sovereign_airlock/policy.py new file mode 100644 index 0000000..dc25603 --- /dev/null +++ b/packages/sovereign-airlock/src/sovereign_airlock/policy.py @@ -0,0 +1,407 @@ +# packages/sovereign-airlock/src/sovereign_airlock/policy.py +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +from .exception import AirlockConfigurationError +from .payload import NormalizedPayload +from .telemetry import AirlockTelemetry + +_VALID_SCOPES: frozenset[str] = frozenset({"raw", "fields", "telemetry"}) +_VALID_ACTIONS: frozenset[str] = frozenset({"allow", "warn", "deny"}) +_VALID_METRICS: frozenset[str] = frozenset({"raw_tokens", "sieved_tokens", "tax_savings_percentage"}) +_POST_SIEVE_METRICS: frozenset[str] = frozenset({"sieved_tokens", "tax_savings_percentage"}) + + +@dataclass(frozen=True) +class PolicyRule: + """Immutable descriptor for a single machine-local governance rule. + + :param name: Human-readable rule identifier used in violation and warning messages. + :type name: str + :param scope: Evaluation domain. One of ``"raw"`` (flat content string), + ``"fields"`` (structured field extraction), or ``"telemetry"`` (numeric metrics). + :type scope: str + :param action: Outcome on match. One of ``"allow"``, ``"warn"``, or ``"deny"``. + :type action: str + :param pattern: Pre-compiled regex applied during ``raw`` and ``fields`` scope evaluation. + ``None`` when unused (i.e. for ``telemetry`` scope rules). Compiled at + :class:`PolicyEngine` initialisation time; a malformed pattern raises + :exc:`~sovereign_airlock.exception.AirlockConfigurationError` before any rule is stored. + :type pattern: re.Pattern[str] | None + :param fields: Dot-notation field paths evaluated during ``fields`` scope matching + (e.g. ``"messages.content"``, ``"tools.description"``). + :type fields: tuple[str, ...] + :param metric: Token-economy metric name evaluated during ``telemetry`` scope matching + (e.g. ``"raw_tokens"``, ``"sieved_tokens"``). + :type metric: str | None + :param threshold: Numeric ceiling beyond which the telemetry rule triggers. + :type threshold: int | float | None + """ + + name: str + scope: str + action: str + pattern: re.Pattern[str] | None = None + fields: tuple[str, ...] = field(default_factory=tuple) + metric: str | None = None + threshold: int | float | None = None + + +@dataclass +class PolicyVerdict: + """Mutable result of a policy evaluation pass over a single :class:`NormalizedPayload`. + + :param allowed: ``True`` if the payload is permitted to continue; ``False`` if any + ``deny`` rule matched or the global token ceiling was breached. + :type allowed: bool + :param violations: Human-readable messages for each triggered ``deny`` rule. + :type violations: list[str] + :param warnings: Human-readable messages for each triggered ``warn`` rule. + :type warnings: list[str] + """ + + allowed: bool = True + violations: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +class PolicyEngine: + """Machine-local policy evaluator that loads governance rules from a YAML configuration file. + + Evaluation is deterministic and fully offline: no network calls are issued, + no model inference is invoked, and no mutable global state is touched. All regex + patterns are compiled at initialisation time; a malformed pattern causes an immediate + :exc:`~sovereign_airlock.exception.AirlockConfigurationError` rather than a deferred + ``re.error`` at evaluation time. + + :param config_path: Filesystem path to the YAML policy configuration file. + :type config_path: str | Path + :raises AirlockConfigurationError: If the file is absent, cannot be parsed as + valid YAML, contains structurally invalid rule definitions, or any rule carries + a malformed regex pattern. + """ + + _rules: list[PolicyRule] + _max_token_ceiling: int + _prose_tax_warning_threshold: float + + def __init__(self, config_path: str | Path) -> None: + """Load and validate the YAML policy configuration. + + :param config_path: Path to the YAML policy file. + :type config_path: str | Path + :raises AirlockConfigurationError: On missing file, malformed YAML, invalid rule + definitions, malformed regex patterns, out-of-range ``prose_tax_warning_threshold``, + or unrecognised telemetry metric names. + """ + path = Path(config_path) + try: + with open(path, "r", encoding="utf-8") as fh: + raw: Any = yaml.safe_load(fh) + except FileNotFoundError: + raise AirlockConfigurationError( + f"Policy configuration not found: {path}" + ) + except yaml.YAMLError as exc: + raise AirlockConfigurationError( + f"Malformed policy YAML at {path}: {exc}" + ) from exc + + if not isinstance(raw, dict): + raise AirlockConfigurationError( + "Policy configuration must be a YAML mapping at the root level." + ) + + global_cfg: dict[str, Any] = raw.get("global") or {} + self._max_token_ceiling = int(global_cfg.get("max_token_ceiling", 0)) + self._prose_tax_warning_threshold = float( + global_cfg.get("prose_tax_warning_threshold", 0.0) + ) + if not (0.0 <= self._prose_tax_warning_threshold <= 1.0): + raise AirlockConfigurationError( + f"prose_tax_warning_threshold must be in [0.0, 1.0]; " + f"got {self._prose_tax_warning_threshold}." + ) + + self._rules = [] + for rule_def in raw.get("rules") or []: + try: + self._rules.append(self._parse_rule(rule_def)) + except (KeyError, TypeError, ValueError) as exc: + raise AirlockConfigurationError( + f"Invalid rule definition {rule_def!r}: {exc}" + ) from exc + + # ------------------------------------------------------------------ + # Internal parsing + # ------------------------------------------------------------------ + + def _parse_rule(self, rule_def: dict[str, Any]) -> PolicyRule: + """Parse and validate a single rule mapping from the YAML configuration. + + Compiles any ``pattern`` string via :func:`re.compile` at parse time so that + malformed patterns raise :exc:`~sovereign_airlock.exception.AirlockConfigurationError` + during :meth:`__init__` rather than a deferred ``re.error`` at evaluation time. + + :param rule_def: Raw rule dictionary extracted from the YAML ``rules`` list. + :type rule_def: dict[str, Any] + :return: A validated, immutable :class:`PolicyRule` with a pre-compiled pattern. + :rtype: PolicyRule + :raises AirlockConfigurationError: If ``pattern`` is present but not a valid regex, + or if a ``telemetry``-scope rule specifies a ``metric`` not in ``_VALID_METRICS``. + :raises ValueError: If ``scope`` or ``action`` carry an unrecognised value. + :raises KeyError: If ``name``, ``scope``, or ``action`` keys are absent. + """ + name = str(rule_def["name"]) + scope = str(rule_def["scope"]) + action = str(rule_def["action"]) + + if scope not in _VALID_SCOPES: + raise ValueError( + f"Unknown scope {scope!r}; expected one of {sorted(_VALID_SCOPES)}." + ) + if action not in _VALID_ACTIONS: + raise ValueError( + f"Unknown action {action!r}; expected one of {sorted(_VALID_ACTIONS)}." + ) + + raw_pattern = rule_def.get("pattern") + compiled: re.Pattern[str] | None = None + if raw_pattern is not None: + try: + compiled = re.compile(str(raw_pattern)) + except re.error as exc: + raise AirlockConfigurationError( + f"Invalid regex pattern in rule '{name}': {exc}" + ) from exc + + raw_metric: str | None = rule_def.get("metric") + if scope == "telemetry" and raw_metric is not None and raw_metric not in _VALID_METRICS: + raise AirlockConfigurationError( + f"Unknown telemetry metric '{raw_metric}' in rule '{name}'; " + f"expected one of {sorted(_VALID_METRICS)}." + ) + + return PolicyRule( + name=name, + scope=scope, + action=action, + pattern=compiled, + fields=tuple(rule_def.get("fields") or ()), + metric=raw_metric, + threshold=rule_def.get("threshold"), + ) + + # ------------------------------------------------------------------ + # Public evaluation interface + # ------------------------------------------------------------------ + + def evaluate( + self, + payload: NormalizedPayload, + telemetry: AirlockTelemetry | None = None, + ) -> PolicyVerdict: + """Evaluate all configured rules against ``payload`` and return a :class:`PolicyVerdict`. + + Evaluation order: + 1. Global ``max_token_ceiling`` check against ``payload.token_estimate``. + 2. Each rule in declaration order; all rules are evaluated regardless of + prior denials so that the full set of violations is surfaced. + + :param payload: The normalised outbound payload under inspection. + :type payload: NormalizedPayload + :param telemetry: Optional post-sieve telemetry. When ``None``, telemetry-scope + rules fall back to ``payload.token_estimate`` for metric resolution. + :type telemetry: AirlockTelemetry | None + :return: A :class:`PolicyVerdict` recording allow/deny state and all messages. + :rtype: PolicyVerdict + """ + verdict = PolicyVerdict() + + if ( + self._max_token_ceiling > 0 + and payload.token_estimate > self._max_token_ceiling + ): + verdict.allowed = False + verdict.violations.append( + f"Global ceiling breach: token_estimate {payload.token_estimate} " + f"exceeds max_token_ceiling {self._max_token_ceiling}." + ) + + for rule in self._rules: + if rule.scope == "raw": + self._evaluate_raw(rule, payload, verdict) + elif rule.scope == "fields": + self._evaluate_fields(rule, payload, verdict) + elif rule.scope == "telemetry": + self._evaluate_telemetry(rule, payload, telemetry, verdict) + + return verdict + + def evaluate_post_sieve(self, telemetry: AirlockTelemetry) -> PolicyVerdict: + """Evaluate telemetry-scope rules that require actual post-sieve data. + + Only rules with metrics in ``_POST_SIEVE_METRICS`` (``sieved_tokens``, + ``tax_savings_percentage``) are evaluated here. ``raw_tokens`` rules and + all ``raw``/``fields`` rules were already assessed during the pre-sieve + :meth:`evaluate` call. Also applies the ``prose_tax_warning_threshold`` check. + + :param telemetry: Post-sieve metrics from + :meth:`~sovereign_airlock.telemetry.AirlockTelemetry.from_sieve_output`. + :type telemetry: AirlockTelemetry + :return: A :class:`PolicyVerdict` recording any post-sieve deny/warn outcomes. + :rtype: PolicyVerdict + """ + verdict = PolicyVerdict() + + for rule in self._rules: + if rule.scope != "telemetry" or rule.metric not in _POST_SIEVE_METRICS or rule.threshold is None: + continue + + if rule.metric == "sieved_tokens": + metric_value: int | float = telemetry.sieved_tokens + elif rule.metric == "tax_savings_percentage": + metric_value = telemetry.tax_savings_percentage + else: + continue + + if metric_value > rule.threshold: + self._apply_action( + rule, + f"Rule '{rule.name}': metric '{rule.metric}' value {metric_value} " + f"exceeds threshold {rule.threshold}.", + verdict, + ) + + if self._prose_tax_warning_threshold > 0.0: + threshold_pct = self._prose_tax_warning_threshold * 100.0 + if telemetry.tax_savings_percentage < threshold_pct: + verdict.warnings.append( + f"Prose Tax savings {telemetry.tax_savings_percentage:.4f}% is below " + f"the configured warning threshold of {threshold_pct:.1f}%." + ) + + return verdict + + # ------------------------------------------------------------------ + # Scope evaluators + # ------------------------------------------------------------------ + + def _evaluate_raw( + self, + rule: PolicyRule, + payload: NormalizedPayload, + verdict: PolicyVerdict, + ) -> None: + if not rule.pattern: + return + flat = " ".join(payload.content) + if rule.pattern.search(flat): + self._apply_action(rule, f"Rule '{rule.name}' matched in raw content.", verdict) + + def _evaluate_fields( + self, + rule: PolicyRule, + payload: NormalizedPayload, + verdict: PolicyVerdict, + ) -> None: + if not rule.pattern or not rule.fields: + return + for field_path in rule.fields: + extracted = self._extract_field(field_path, payload) + if extracted and rule.pattern.search(extracted): + self._apply_action( + rule, + f"Rule '{rule.name}' matched in field '{field_path}'.", + verdict, + ) + return + + def _evaluate_telemetry( + self, + rule: PolicyRule, + payload: NormalizedPayload, + telemetry: AirlockTelemetry | None, + verdict: PolicyVerdict, + ) -> None: + if rule.metric is None or rule.threshold is None: + return + + metric_value: int | float + if telemetry is None: + if rule.metric in _POST_SIEVE_METRICS: + return + metric_value = payload.token_estimate + elif rule.metric == "raw_tokens": + metric_value = telemetry.raw_tokens + elif rule.metric == "sieved_tokens": + metric_value = telemetry.sieved_tokens + elif rule.metric == "tax_savings_percentage": + metric_value = telemetry.tax_savings_percentage + else: + return + + if metric_value > rule.threshold: + self._apply_action( + rule, + f"Rule '{rule.name}': metric '{rule.metric}' value {metric_value} " + f"exceeds threshold {rule.threshold}.", + verdict, + ) + + # ------------------------------------------------------------------ + # Action dispatcher + # ------------------------------------------------------------------ + + def _apply_action( + self, + rule: PolicyRule, + message: str, + verdict: PolicyVerdict, + ) -> None: + if rule.action == "deny": + verdict.allowed = False + verdict.violations.append(message) + elif rule.action == "warn": + verdict.warnings.append(message) + # "allow" action is an explicit no-op: matching payload passes unconditionally. + + # ------------------------------------------------------------------ + # Field extraction + # ------------------------------------------------------------------ + + def _extract_field(self, field_path: str, payload: NormalizedPayload) -> str: + """Extract a dot-notation field value from a :class:`NormalizedPayload`. + + Supported paths and their mapped sources: + - ``messages.*``, ``input``, ``prompt`` → ``" ".join(payload.content)`` + - ``tools.`` → concatenated tool ``sub_field`` values + - Anything else → string representation of ``payload.metadata[path]`` + + :param field_path: Dot-separated path string (e.g. ``"messages.content"``). + :type field_path: str + :param payload: The normalised payload from which to extract. + :type payload: NormalizedPayload + :return: Extracted string value, or ``""`` when the path resolves to nothing. + :rtype: str + """ + parts = field_path.split(".", 1) + top = parts[0] + + if top in ("messages", "input", "prompt"): + return " ".join(payload.content) + + if top == "tools" and len(parts) > 1: + sub_field = parts[1] + return " ".join( + str(tool.get(sub_field, "")) + for tool in payload.tools + if tool.get(sub_field) + ) + + val = payload.metadata.get(top) + return str(val) if val is not None else "" diff --git a/packages/sovereign-airlock/src/sovereign_airlock/receipt.py b/packages/sovereign-airlock/src/sovereign_airlock/receipt.py new file mode 100644 index 0000000..bb81f97 --- /dev/null +++ b/packages/sovereign-airlock/src/sovereign_airlock/receipt.py @@ -0,0 +1,114 @@ +# packages/sovereign-airlock/src/sovereign_airlock/receipt.py +import logging +from typing import Any + +from sovereign_core import ForensicReceipt, SovereignKeyManager +from sovereign_ledger import SovereignLedger + +from .telemetry import AirlockTelemetry + +_logger = logging.getLogger(__name__) + + +class ReceiptBuilder: + """Assembles and commits cryptographically signed :class:`ForensicReceipt` records for + Airlock boundary crossing events. + + Coordinates with :class:`~sovereign_core.SovereignKeyManager` for Ed25519 signing + and with :class:`~sovereign_ledger.SovereignLedger` for durable evidence commit. + Ledger write failures are non-fatal: a warning is emitted locally and the receipt + is returned regardless, so that outbound transmission is never blocked by a + storage-tier anomaly (per SAR-0002 and SAR-0009). + + :param key_manager: Initialised :class:`~sovereign_core.SovereignKeyManager` holding + the node's Ed25519 identity keypair. + :type key_manager: SovereignKeyManager + :param ledger: Optional :class:`~sovereign_ledger.SovereignLedger` instance for + durable receipt persistence. When ``None``, receipts are signed but not stored. + :type ledger: SovereignLedger | None + """ + + _key_manager: SovereignKeyManager + _ledger: SovereignLedger | None + + def __init__( + self, + key_manager: SovereignKeyManager, + ledger: SovereignLedger | None = None, + ) -> None: + """Initialise the builder with a signing identity and optional durable store. + + :param key_manager: Configured key manager for Ed25519 receipt signing. + :type key_manager: SovereignKeyManager + :param ledger: Optional ledger for Write-Side Custody commit. + :type ledger: SovereignLedger | None + """ + self._key_manager = key_manager + self._ledger = ledger + + def build_and_commit( + self, + sieved_content: str, + telemetry: AirlockTelemetry, + policy_warnings: list[str], + source: str, + ) -> ForensicReceipt: + """Build a signed :class:`ForensicReceipt` and attempt a durable ledger commit. + + Assembles the receipt metadata from the Airlock boundary telemetry, optional + policy warnings, and source transport identifier. Signs the payload via + :meth:`~sovereign_core.SovereignKeyManager.generate_receipt` using the node's + Ed25519 private key, then commits to :class:`~sovereign_ledger.SovereignLedger` + if one is configured. + + Ledger write failure is non-fatal. If :meth:`~sovereign_ledger.SovereignLedger.append_receipt` + raises, the failure is logged at ``WARNING`` level with the payload hash and + exception details, and the signed receipt is returned to the caller regardless. + This behaviour preserves the SAR-0009 intent — evidence of the boundary crossing + is created before transmission — while honouring the plan's failure contract: + evidence recording must never block or abort outbound transmission. + + :param sieved_content: The Prose-Tax-minimised string produced by the sieve pass. + :type sieved_content: str + :param telemetry: Sieve metrics for this boundary crossing event. + :type telemetry: AirlockTelemetry + :param policy_warnings: List of non-fatal policy warning messages collected during + evaluation. Omitted from metadata when empty. + :type policy_warnings: list[str] + :param source: Source transport identifier from the :class:`~sovereign_airlock.payload.NormalizedPayload`. + :type source: str + :return: A signed :class:`~sovereign_core.ForensicReceipt` whose ``metadata`` includes + ``boundary``, ``source_transport``, ``prose_tax_summary``, and optionally + ``policy_warnings``. + :rtype: ForensicReceipt + :raises Exception: Any exception raised by :meth:`~sovereign_core.SovereignKeyManager.generate_receipt` + propagates directly (signing failures are not silenced). + """ + metadata: dict[str, Any] = { + "boundary": "sovereign-sdk-airlock", + "source_transport": source, + "prose_tax_summary": { + "raw_token_count": telemetry.raw_tokens, + "optimized_token_count": telemetry.sieved_tokens, + "tax_savings_percentage": telemetry.tax_savings_percentage, + }, + } + if policy_warnings: + metadata["policy_warnings"] = policy_warnings + + receipt: ForensicReceipt = self._key_manager.generate_receipt( + {"content": sieved_content}, + metadata, + ) + + if self._ledger is not None: + try: + self._ledger.append_receipt(receipt, sieved_content) + except Exception as ledger_exc: + _logger.warning( + "Airlock ledger write failure — payload_hash=%s error=%r", + telemetry.payload_hash, + ledger_exc, + ) + + return receipt diff --git a/packages/sovereign-airlock/src/sovereign_airlock/telemetry.py b/packages/sovereign-airlock/src/sovereign_airlock/telemetry.py new file mode 100644 index 0000000..8045550 --- /dev/null +++ b/packages/sovereign-airlock/src/sovereign_airlock/telemetry.py @@ -0,0 +1,71 @@ +# packages/sovereign-airlock/src/sovereign_airlock/telemetry.py +import hashlib +from dataclasses import dataclass + +from sovereign_sieve import SieveOutput + + +@dataclass(frozen=True) +class AirlockTelemetry: + """Immutable metrics record produced by the Airlock sieve convergence pass. + + Carries the four Component C metrics defined in the Airlock specification: + ``raw_tokens``, ``sieved_tokens``, ``tax_savings_percentage``, and + ``payload_hash``. All fields are set at construction time and are + immutable for the lifetime of the record. + + :param raw_tokens: Estimated token count of the original pre-sieve content. + :type raw_tokens: int + :param sieved_tokens: Estimated token count after Prose Tax minimisation. + :type sieved_tokens: int + :param tax_savings_percentage: Percentage reduction from raw to sieved, clamped + to ``[0.0, 100.0]`` and rounded to four decimal places. Explicitly + ``0.0`` when ``raw_tokens == 0`` (ZeroDivisionError guard) or when the + sieve expands content beyond the raw token count. + :type tax_savings_percentage: float + :param payload_hash: SHA-256 hex digest of the raw (pre-sieve) content string. + Serves as an observable identifier for cross-referencing the incoming + payload across telemetry logs and ledger entries. + :type payload_hash: str + """ + + raw_tokens: int + sieved_tokens: int + tax_savings_percentage: float + payload_hash: str + + @classmethod + def from_sieve_output(cls, sieve_output: SieveOutput, raw_content: str) -> "AirlockTelemetry": + """Construct an :class:`AirlockTelemetry` from a :class:`SieveOutput` and the raw content. + + Re-derives ``tax_savings_percentage`` from the token counts rather than + trusting the value carried inside ``sieve_output``, providing an independent + layer of defence against upstream floating-point drift. Includes an explicit + guard: if ``raw_tokens == 0``, ``tax_savings_percentage`` is forced to ``0.0`` + to prevent ZeroDivisionError on zero-token or null-pass edge cases. + + :param sieve_output: The structured result of a ``sieve_with_metrics`` pass. + :type sieve_output: SieveOutput + :param raw_content: The original string fed into the sieve, used to derive + ``payload_hash`` via SHA-256. + :type raw_content: str + :return: A frozen :class:`AirlockTelemetry` instance. + :rtype: AirlockTelemetry + """ + raw_tokens: int = sieve_output.raw_token_count + sieved_tokens: int = sieve_output.optimized_token_count + payload_hash: str = hashlib.sha256(raw_content.encode("utf-8")).hexdigest() + + if raw_tokens == 0: + tax_savings_percentage: float = 0.0 + else: + tax_savings_percentage = max(0.0, min(100.0, round( + (raw_tokens - sieved_tokens) / raw_tokens * 100.0, 4 + ))) + + return cls( + raw_tokens=raw_tokens, + sieved_tokens=sieved_tokens, + tax_savings_percentage=tax_savings_percentage, + payload_hash=payload_hash, + ) diff --git a/packages/sovereign-airlock/tests/conftest.py b/packages/sovereign-airlock/tests/conftest.py new file mode 100644 index 0000000..691b27c --- /dev/null +++ b/packages/sovereign-airlock/tests/conftest.py @@ -0,0 +1,60 @@ +import os +from pathlib import Path + +import pytest +import yaml + +_MINIMAL_POLICY: dict = { + "version": "1.0", + "global": { + "max_token_ceiling": 40000, + "prose_tax_warning_threshold": 0.0, + }, + "rules": [ + { + "name": "block_private_keys", + "scope": "raw", + "pattern": "-----BEGIN PRIVATE KEY-----", + "action": "deny", + }, + { + "name": "guard_internal_namespaces", + "scope": "fields", + "fields": ["messages.content", "input", "prompt", "tools.description"], + "pattern": r"internal\.sovereign\.local", + "action": "warn", + }, + { + "name": "excessive_context", + "scope": "telemetry", + "metric": "raw_tokens", + "threshold": 30000, + "action": "warn", + }, + ], +} + + +@pytest.fixture +def policy_path(tmp_path: Path) -> Path: + """Write the standard test policy YAML and return its path.""" + path = tmp_path / "policy.yaml" + path.write_text(yaml.dump(_MINIMAL_POLICY), encoding="utf-8") + return path + + +@pytest.fixture +def allow_all_policy_path(tmp_path: Path) -> Path: + """Write a permissive policy with no rules.""" + config = {"version": "1.0", "global": {}, "rules": []} + path = tmp_path / "allow_all.yaml" + path.write_text(yaml.dump(config), encoding="utf-8") + return path + + +@pytest.fixture +def sovereign_secret(monkeypatch) -> str: + """Inject a deterministic test passphrase for SovereignKeyManager.""" + secret = "airlock-test-node-secret-v1" + monkeypatch.setenv("SOVEREIGN_NODE_SECRET", secret) + return secret diff --git a/packages/sovereign-airlock/tests/test_boundary.py b/packages/sovereign-airlock/tests/test_boundary.py new file mode 100644 index 0000000..5181e61 --- /dev/null +++ b/packages/sovereign-airlock/tests/test_boundary.py @@ -0,0 +1,383 @@ +"""TDD test suite for sovereign_airlock.boundary — write before implementation.""" + +import logging +import types +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import yaml + +from sovereign_ledger import SovereignLedger, SovereignStorageError + +from sovereign_airlock.boundary import AirlockBoundary, AirlockResult +from sovereign_airlock.exception import AirlockPolicyViolation +from sovereign_airlock.payload import ( + NormalizedPayload, + normalize_anthropic, + normalize_openai, + normalize_raw, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def mem_ledger() -> SovereignLedger: + ledger = SovereignLedger(":memory:") + yield ledger + ledger.close() + + +@pytest.fixture +def airlock(tmp_path: Path, policy_path: Path, mem_ledger: SovereignLedger, sovereign_secret: str) -> AirlockBoundary: + """Fully configured AirlockBoundary with an in-memory ledger.""" + return AirlockBoundary( + policy_path=policy_path, + signing_key=str(tmp_path / "keys"), + ledger=mem_ledger, + ) + + +@pytest.fixture +def airlock_no_ledger(tmp_path: Path, policy_path: Path, sovereign_secret: str) -> AirlockBoundary: + """AirlockBoundary operating without a ledger.""" + return AirlockBoundary( + policy_path=policy_path, + signing_key=str(tmp_path / "keys"), + ledger=None, + ) + + +# --------------------------------------------------------------------------- +# TestAirlockBoundaryHappyPath +# --------------------------------------------------------------------------- + +class TestAirlockBoundaryHappyPath: + async def test_process_returns_airlock_result(self, airlock: AirlockBoundary) -> None: + """process() returns an AirlockResult for a clean payload.""" + payload = normalize_raw("Analyse the temperature dataset.") + result = await airlock.process(payload) + assert isinstance(result, AirlockResult) + + async def test_result_contains_sieved_content(self, airlock: AirlockBoundary) -> None: + """AirlockResult.sieved_content holds the Prose-Tax-minimised string.""" + payload = normalize_raw("Hello! Please just run the analysis.") + result = await airlock.process(payload) + assert isinstance(result.sieved_content, str) + assert len(result.sieved_content) > 0 + + async def test_result_contains_telemetry(self, airlock: AirlockBoundary) -> None: + """AirlockResult.telemetry carries non-None AirlockTelemetry.""" + from sovereign_airlock.telemetry import AirlockTelemetry + + payload = normalize_raw("Run the pipeline.") + result = await airlock.process(payload) + assert isinstance(result.telemetry, AirlockTelemetry) + + async def test_result_contains_signed_receipt(self, airlock: AirlockBoundary) -> None: + """AirlockResult.receipt is a signed ForensicReceipt when signing succeeds.""" + payload = normalize_raw("Clean payload for signing.") + result = await airlock.process(payload) + assert result.receipt is not None + assert "signature" in result.receipt + assert "payload_hash" in result.receipt + + async def test_result_policy_warnings_empty_for_clean_payload(self, airlock: AirlockBoundary) -> None: + """A payload triggering no policy rules produces an empty warnings list.""" + payload = normalize_raw("Analyse the public dataset.") + result = await airlock.process(payload) + assert result.policy_warnings == [] + + async def test_ledger_receives_receipt_after_process( + self, airlock: AirlockBoundary, mem_ledger: SovereignLedger + ) -> None: + """The ledger chain is extended by one after a successful process call.""" + payload = normalize_raw("Governance test payload.") + result = await airlock.process(payload) + assert result.receipt is not None + assert mem_ledger.verify_ledger_integrity(expected_tip_hash=result.receipt["payload_hash"]) + + +# --------------------------------------------------------------------------- +# TestAirlockBoundaryPolicyDenial +# --------------------------------------------------------------------------- + +class TestAirlockBoundaryPolicyDenial: + async def test_deny_raises_airlock_policy_violation(self, airlock: AirlockBoundary) -> None: + """A payload matching a deny rule raises AirlockPolicyViolation.""" + payload = normalize_raw("context: -----BEGIN PRIVATE KEY----- MIII...") + with pytest.raises(AirlockPolicyViolation): + await airlock.process(payload) + + async def test_policy_violation_message_contains_rule_name(self, airlock: AirlockBoundary) -> None: + """AirlockPolicyViolation message identifies the violated rule name.""" + payload = normalize_raw("-----BEGIN PRIVATE KEY-----") + with pytest.raises(AirlockPolicyViolation, match="block_private_keys"): + await airlock.process(payload) + + async def test_deny_does_not_commit_to_ledger( + self, airlock: AirlockBoundary, mem_ledger: SovereignLedger + ) -> None: + """Denied payloads produce no ledger entry.""" + payload = normalize_raw("-----BEGIN PRIVATE KEY-----") + with pytest.raises(AirlockPolicyViolation): + await airlock.process(payload) + assert mem_ledger.verify_ledger_integrity() + # Chain is intact but empty — no receipt was committed + assert mem_ledger.verify_ledger_integrity(expected_tip_hash=None) + + async def test_post_sieve_telemetry_deny_raises_policy_violation( + self, tmp_path: Path, sovereign_secret: str + ) -> None: + """A post-sieve deny rule on sieved_tokens raises AirlockPolicyViolation after the sieve pass.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + { + "name": "sieved_hard_cap", + "scope": "telemetry", + "metric": "sieved_tokens", + "threshold": 1, + "action": "deny", + } + ], + } + policy_p = tmp_path / "policy.yaml" + policy_p.write_text(yaml.dump(config), encoding="utf-8") + boundary = AirlockBoundary( + policy_path=policy_p, + signing_key=str(tmp_path / "keys"), + ) + payload = normalize_raw("This payload has more than one sieved token.") + with pytest.raises(AirlockPolicyViolation, match="sieved_hard_cap"): + await boundary.process(payload) + + +# --------------------------------------------------------------------------- +# TestAirlockBoundaryPolicyWarning +# --------------------------------------------------------------------------- + +class TestAirlockBoundaryPolicyWarning: + async def test_warn_returns_result_with_policy_warnings(self, airlock: AirlockBoundary) -> None: + """A payload triggering a warn rule returns AirlockResult with non-empty policy_warnings.""" + payload = NormalizedPayload( + source="openai", + content=["contact internal.sovereign.local for config"], + metadata={}, + tools=[], + token_estimate=50, + ) + result = await airlock.process(payload) + assert result.policy_warnings != [] + assert any("guard_internal_namespaces" in w for w in result.policy_warnings) + + async def test_warn_does_not_block_process(self, airlock: AirlockBoundary) -> None: + """A warn-level rule does not prevent AirlockResult from being returned.""" + payload = NormalizedPayload( + source="raw", + content=["internal.sovereign.local endpoint config"], + metadata={}, + tools=[], + token_estimate=50, + ) + result = await airlock.process(payload) + assert isinstance(result, AirlockResult) + + async def test_warn_policy_warnings_sealed_in_receipt_metadata( + self, airlock: AirlockBoundary + ) -> None: + """Policy warnings from warn rules are propagated into the receipt metadata.""" + payload = NormalizedPayload( + source="raw", + content=["access internal.sovereign.local"], + metadata={}, + tools=[], + token_estimate=50, + ) + result = await airlock.process(payload) + assert result.receipt is not None + assert "policy_warnings" in result.receipt["metadata"] + + +# --------------------------------------------------------------------------- +# TestAirlockBoundaryTransportNeutrality +# --------------------------------------------------------------------------- + +class TestAirlockBoundaryTransportNeutrality: + async def test_openai_normalized_payload_processes_correctly( + self, airlock: AirlockBoundary + ) -> None: + """OpenAI-normalised payload traverses the full governance pipeline.""" + request = { + "model": "gpt-4o", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Summarise the quarterly report."}, + ], + } + payload = normalize_openai(request) + result = await airlock.process(payload) + assert result.sieved_content + assert result.telemetry.raw_tokens >= 0 + + async def test_anthropic_normalized_payload_processes_correctly( + self, airlock: AirlockBoundary + ) -> None: + """Anthropic-normalised payload traverses the full governance pipeline.""" + request = { + "model": "claude-sonnet-4-6", + "system": "You are a data analyst.", + "messages": [{"role": "user", "content": "Analyse the dataset."}], + } + payload = normalize_anthropic(request) + result = await airlock.process(payload) + assert result.sieved_content + assert result.telemetry.raw_tokens >= 0 + + async def test_raw_normalized_payload_processes_correctly( + self, airlock: AirlockBoundary + ) -> None: + """Raw-normalised payload traverses the full governance pipeline.""" + payload = normalize_raw("Process this raw text payload.", source="custom") + result = await airlock.process(payload) + assert result.sieved_content + assert result.telemetry is not None + + +# --------------------------------------------------------------------------- +# TestAirlockBoundaryResiliency +# --------------------------------------------------------------------------- + +class TestAirlockBoundaryResiliency: + async def test_ledger_write_failure_does_not_abort_result( + self, + tmp_path: Path, + policy_path: Path, + sovereign_secret: str, + caplog, + ) -> None: + """A ledger write failure is non-fatal; process() returns a result with a valid receipt.""" + failing_ledger = MagicMock(spec=SovereignLedger) + failing_ledger.append_receipt.side_effect = SovereignStorageError("simulated failure") + boundary = AirlockBoundary( + policy_path=policy_path, + signing_key=str(tmp_path / "keys"), + ledger=failing_ledger, + ) + payload = normalize_raw("governance test") + + with caplog.at_level(logging.WARNING): + result = await boundary.process(payload) + + # Signing succeeded; ledger write failed silently — receipt is still returned + assert result is not None + assert result.receipt is not None + + async def test_process_succeeds_without_ledger( + self, airlock_no_ledger: AirlockBoundary + ) -> None: + """AirlockBoundary operates correctly when no ledger is configured.""" + payload = normalize_raw("no ledger test payload") + result = await airlock_no_ledger.process(payload) + assert isinstance(result, AirlockResult) + assert result.receipt is not None + + async def test_sieve_metrics_are_accurate(self, airlock: AirlockBoundary) -> None: + """Telemetry metrics from the sieve pass are numerically consistent.""" + raw = "Hello! Please just help me run the analysis pipeline." + payload = normalize_raw(raw) + result = await airlock.process(payload) + assert result.telemetry.raw_tokens >= result.telemetry.sieved_tokens + assert 0.0 <= result.telemetry.tax_savings_percentage <= 100.0 + + +# --------------------------------------------------------------------------- +# TestProseTaxThreshold +# --------------------------------------------------------------------------- + +class TestProseTaxThreshold: + async def test_low_savings_triggers_prose_tax_warning( + self, tmp_path: Path, sovereign_secret: str + ) -> None: + """Prose tax threshold warning fires when sieve savings fall below the threshold.""" + config = { + "version": "1.0", + "global": {"prose_tax_warning_threshold": 0.35}, + "rules": [], + } + policy_p = tmp_path / "policy.yaml" + policy_p.write_text(yaml.dump(config), encoding="utf-8") + boundary = AirlockBoundary( + policy_path=policy_p, + signing_key=str(tmp_path / "keys"), + ) + # Technical SQL payload with no filler words — sieve removes nothing → ~0% savings. + # With threshold 0.35 (35%), savings of ~0% is below threshold → warning expected. + payload = normalize_raw("SELECT id, temperature FROM sensor_data WHERE node_id = 42") + result = await boundary.process(payload) + assert any("threshold" in w.lower() for w in result.policy_warnings) + + async def test_threshold_zero_disables_prose_tax_warning( + self, tmp_path: Path, sovereign_secret: str + ) -> None: + """prose_tax_warning_threshold of 0.0 disables the prose tax check entirely.""" + config = { + "version": "1.0", + "global": {"prose_tax_warning_threshold": 0.0}, + "rules": [], + } + policy_p = tmp_path / "policy.yaml" + policy_p.write_text(yaml.dump(config), encoding="utf-8") + boundary = AirlockBoundary( + policy_path=policy_p, + signing_key=str(tmp_path / "keys"), + ) + payload = normalize_raw("SELECT id FROM sensor_data") + result = await boundary.process(payload) + assert result.policy_warnings == [] + + +# --------------------------------------------------------------------------- +# TestNormalizedPayloadImmutability +# --------------------------------------------------------------------------- + +class TestNormalizedPayloadImmutability: + def test_content_is_immutable_tuple(self) -> None: + """NormalizedPayload.content is stored as an immutable tuple.""" + payload = normalize_raw("test content") + assert isinstance(payload.content, tuple) + with pytest.raises((AttributeError, TypeError)): + payload.content.append("mutation") # type: ignore[attr-defined] + + def test_metadata_is_mapping_proxy(self) -> None: + """NormalizedPayload.metadata is stored as a MappingProxyType.""" + payload = normalize_raw("test", metadata={"key": "value"}) + assert isinstance(payload.metadata, types.MappingProxyType) + with pytest.raises(TypeError): + payload.metadata["new_key"] = "mutation" # type: ignore[index] + + def test_tools_is_immutable_tuple(self) -> None: + """NormalizedPayload.tools is stored as an immutable tuple.""" + request = { + "messages": [{"role": "user", "content": "test"}], + "tools": [{"name": "t", "description": "d"}], + } + payload = normalize_openai(request) + assert isinstance(payload.tools, tuple) + with pytest.raises((AttributeError, TypeError)): + payload.tools.append({"name": "mutation"}) # type: ignore[attr-defined] + + def test_tool_entries_are_mapping_proxies(self) -> None: + """Each tool entry inside NormalizedPayload.tools is a MappingProxyType.""" + request = { + "messages": [{"role": "user", "content": "test"}], + "tools": [{"name": "tool1", "description": "does things"}], + } + payload = normalize_openai(request) + assert len(payload.tools) == 1 + assert isinstance(payload.tools[0], types.MappingProxyType) + assert payload.tools[0].get("name") == "tool1" diff --git a/packages/sovereign-airlock/tests/test_policy.py b/packages/sovereign-airlock/tests/test_policy.py new file mode 100644 index 0000000..7bbc661 --- /dev/null +++ b/packages/sovereign-airlock/tests/test_policy.py @@ -0,0 +1,473 @@ +"""TDD test suite for sovereign_airlock.policy — write before implementation.""" + +from pathlib import Path + +import pytest +import yaml + +from sovereign_airlock.exception import AirlockConfigurationError +from sovereign_airlock.payload import NormalizedPayload, normalize_raw +from sovereign_airlock.policy import PolicyEngine, PolicyRule, PolicyVerdict +from sovereign_airlock.telemetry import AirlockTelemetry +from sovereign_sieve import sieve_with_metrics + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write_policy(tmp_path: Path, config: dict) -> Path: + path = tmp_path / "policy.yaml" + path.write_text(yaml.dump(config), encoding="utf-8") + return path + + +def _make_telemetry(raw_tokens: int = 100, sieved_tokens: int = 80) -> AirlockTelemetry: + import hashlib + return AirlockTelemetry( + raw_tokens=raw_tokens, + sieved_tokens=sieved_tokens, + tax_savings_percentage=round((raw_tokens - sieved_tokens) / raw_tokens * 100.0, 4) if raw_tokens else 0.0, + payload_hash=hashlib.sha256(b"test").hexdigest(), + ) + + +# --------------------------------------------------------------------------- +# TestPolicyLoading +# --------------------------------------------------------------------------- + +class TestPolicyLoading: + def test_loads_valid_yaml(self, policy_path: Path) -> None: + """PolicyEngine initialises cleanly from a well-formed YAML config.""" + engine = PolicyEngine(policy_path) + assert engine is not None + + def test_raises_on_missing_file(self, tmp_path: Path) -> None: + """Missing policy file raises AirlockConfigurationError.""" + with pytest.raises(AirlockConfigurationError, match="not found"): + PolicyEngine(tmp_path / "nonexistent.yaml") + + def test_raises_on_malformed_yaml(self, tmp_path: Path) -> None: + """Malformed YAML raises AirlockConfigurationError.""" + bad_path = tmp_path / "bad.yaml" + bad_path.write_bytes(b":\tinvalid:\n - yaml: [unclosed") + with pytest.raises(AirlockConfigurationError, match="Malformed"): + PolicyEngine(bad_path) + + def test_raises_on_invalid_scope(self, tmp_path: Path) -> None: + """An unrecognised rule scope raises AirlockConfigurationError.""" + config = { + "version": "1.0", + "global": {}, + "rules": [{"name": "bad_scope", "scope": "network", "action": "deny", "pattern": "x"}], + } + with pytest.raises(AirlockConfigurationError): + PolicyEngine(_write_policy(tmp_path, config)) + + def test_raises_on_invalid_action(self, tmp_path: Path) -> None: + """An unrecognised rule action raises AirlockConfigurationError.""" + config = { + "version": "1.0", + "global": {}, + "rules": [{"name": "bad_action", "scope": "raw", "action": "quarantine", "pattern": "x"}], + } + with pytest.raises(AirlockConfigurationError): + PolicyEngine(_write_policy(tmp_path, config)) + + def test_accepts_empty_rules_list(self, tmp_path: Path) -> None: + """A policy with an empty rules list is valid and permits everything.""" + config = {"version": "1.0", "global": {}, "rules": []} + engine = PolicyEngine(_write_policy(tmp_path, config)) + payload = normalize_raw("Hello world") + verdict = engine.evaluate(payload) + assert verdict.allowed is True + assert verdict.violations == [] + assert verdict.warnings == [] + + def test_raises_on_non_mapping_yaml(self, tmp_path: Path) -> None: + """A YAML file whose root is not a mapping raises AirlockConfigurationError.""" + path = tmp_path / "list.yaml" + path.write_text("- item1\n- item2\n", encoding="utf-8") + with pytest.raises(AirlockConfigurationError, match="mapping"): + PolicyEngine(path) + + def test_raises_on_malformed_regex_pattern(self, tmp_path: Path) -> None: + """A rule with an invalid regex pattern raises AirlockConfigurationError at init time.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + {"name": "bad_regex", "scope": "raw", "action": "deny", "pattern": "[invalid("} + ], + } + with pytest.raises(AirlockConfigurationError, match="bad_regex"): + PolicyEngine(_write_policy(tmp_path, config)) + + +# --------------------------------------------------------------------------- +# TestRawScopeEvaluation +# --------------------------------------------------------------------------- + +class TestRawScopeEvaluation: + def test_deny_on_private_key_pattern_match(self, policy_path: Path) -> None: + """Raw deny rule blocks payloads containing a private key marker.""" + engine = PolicyEngine(policy_path) + payload = normalize_raw("context: -----BEGIN PRIVATE KEY----- abc123") + verdict = engine.evaluate(payload) + assert verdict.allowed is False + assert len(verdict.violations) == 1 + assert "block_private_keys" in verdict.violations[0] + + def test_allow_when_no_raw_pattern_matches(self, policy_path: Path) -> None: + """Clean content passes the raw scope without violations.""" + engine = PolicyEngine(policy_path) + payload = normalize_raw("Please analyse the temperature dataset.") + verdict = engine.evaluate(payload) + assert verdict.allowed is True + assert verdict.violations == [] + + def test_warn_action_populates_warnings_and_allows(self, tmp_path: Path) -> None: + """Warn action adds to warnings without setting allowed=False.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + {"name": "warn_on_todo", "scope": "raw", "pattern": "TODO", "action": "warn"} + ], + } + engine = PolicyEngine(_write_policy(tmp_path, config)) + payload = normalize_raw("TODO: refactor this section") + verdict = engine.evaluate(payload) + assert verdict.allowed is True + assert len(verdict.warnings) == 1 + assert "warn_on_todo" in verdict.warnings[0] + + def test_multiple_raw_rules_all_evaluated(self, tmp_path: Path) -> None: + """All matching deny rules are collected; allowed becomes False on first deny.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + {"name": "rule_a", "scope": "raw", "pattern": "FORBIDDEN_A", "action": "deny"}, + {"name": "rule_b", "scope": "raw", "pattern": "FORBIDDEN_B", "action": "deny"}, + ], + } + engine = PolicyEngine(_write_policy(tmp_path, config)) + payload = normalize_raw("FORBIDDEN_A and FORBIDDEN_B in payload") + verdict = engine.evaluate(payload) + assert verdict.allowed is False + assert len(verdict.violations) == 2 + + def test_allow_action_is_explicit_no_op(self, tmp_path: Path) -> None: + """An explicit allow-scope rule matching the payload leaves verdict clean.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + {"name": "explicit_allow", "scope": "raw", "pattern": "safe", "action": "allow"} + ], + } + engine = PolicyEngine(_write_policy(tmp_path, config)) + verdict = engine.evaluate(normalize_raw("this is safe content")) + assert verdict.allowed is True + assert verdict.violations == [] + assert verdict.warnings == [] + + +# --------------------------------------------------------------------------- +# TestFieldsScopeEvaluation +# --------------------------------------------------------------------------- + +class TestFieldsScopeEvaluation: + def test_match_in_messages_content_emits_warning(self, policy_path: Path) -> None: + """Fields rule matches internal namespace reference in payload content.""" + engine = PolicyEngine(policy_path) + payload = NormalizedPayload( + source="openai", + content=["connect to internal.sovereign.local for config"], + metadata={}, + tools=[], + token_estimate=50, + ) + verdict = engine.evaluate(payload) + assert verdict.allowed is True + assert any("guard_internal_namespaces" in w for w in verdict.warnings) + + def test_no_match_in_fields_clears_verdict(self, policy_path: Path) -> None: + """Content without the guarded pattern produces no field warnings.""" + engine = PolicyEngine(policy_path) + payload = NormalizedPayload( + source="openai", + content=["Analyse the public dataset at api.example.com"], + metadata={}, + tools=[], + token_estimate=50, + ) + verdict = engine.evaluate(payload) + assert not any("guard_internal_namespaces" in w for w in verdict.warnings) + + def test_match_in_tools_description(self, tmp_path: Path) -> None: + """Fields rule matches a pattern inside a tool description.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + { + "name": "no_internal_tools", + "scope": "fields", + "fields": ["tools.description"], + "pattern": "internal_service", + "action": "deny", + } + ], + } + engine = PolicyEngine(_write_policy(tmp_path, config)) + payload = NormalizedPayload( + source="openai", + content=["run the tool"], + metadata={}, + tools=[{"name": "secret_tool", "description": "calls internal_service endpoint"}], + token_estimate=20, + ) + verdict = engine.evaluate(payload) + assert verdict.allowed is False + assert "no_internal_tools" in verdict.violations[0] + + +# --------------------------------------------------------------------------- +# TestTelemetryScopeEvaluation +# --------------------------------------------------------------------------- + +class TestTelemetryScopeEvaluation: + def test_warn_when_raw_tokens_exceed_threshold(self, policy_path: Path) -> None: + """Telemetry rule emits warning when raw_tokens exceed configured threshold.""" + engine = PolicyEngine(policy_path) + payload = normalize_raw("x") + telemetry = _make_telemetry(raw_tokens=35000, sieved_tokens=30000) + verdict = engine.evaluate(payload, telemetry=telemetry) + assert any("excessive_context" in w for w in verdict.warnings) + + def test_no_telemetry_warning_below_threshold(self, policy_path: Path) -> None: + """Telemetry rule does not fire when metric is within threshold.""" + engine = PolicyEngine(policy_path) + payload = normalize_raw("x") + telemetry = _make_telemetry(raw_tokens=5000, sieved_tokens=4000) + verdict = engine.evaluate(payload, telemetry=telemetry) + assert not any("excessive_context" in w for w in verdict.warnings) + + def test_sieved_tokens_rule_skipped_when_telemetry_absent(self, tmp_path: Path) -> None: + """With telemetry=None, a sieved_tokens rule is silently skipped, not proxied.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + { + "name": "tight_sieved_limit", + "scope": "telemetry", + "metric": "sieved_tokens", + "threshold": 10, + "action": "warn", + } + ], + } + engine = PolicyEngine(_write_policy(tmp_path, config)) + payload = NormalizedPayload( + source="raw", + content=["payload with many tokens"], + metadata={}, + tools=[], + token_estimate=5000, + ) + verdict = engine.evaluate(payload, telemetry=None) + assert not any("tight_sieved_limit" in w for w in verdict.warnings) + + def test_tax_savings_rule_skipped_when_telemetry_absent(self, tmp_path: Path) -> None: + """With telemetry=None, a tax_savings_percentage rule is silently skipped.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + { + "name": "low_savings", + "scope": "telemetry", + "metric": "tax_savings_percentage", + "threshold": 5.0, + "action": "warn", + } + ], + } + engine = PolicyEngine(_write_policy(tmp_path, config)) + payload = NormalizedPayload( + source="raw", + content=["content"], + metadata={}, + tools=[], + token_estimate=500, + ) + verdict = engine.evaluate(payload, telemetry=None) + assert not any("low_savings" in w for w in verdict.warnings) + + def test_evaluate_post_sieve_fires_sieved_tokens_warn_rule(self, tmp_path: Path) -> None: + """evaluate_post_sieve() fires a sieved_tokens rule against actual post-sieve telemetry.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + { + "name": "post_sieve_cap", + "scope": "telemetry", + "metric": "sieved_tokens", + "threshold": 50, + "action": "warn", + } + ], + } + engine = PolicyEngine(_write_policy(tmp_path, config)) + telemetry = _make_telemetry(raw_tokens=200, sieved_tokens=100) + verdict = engine.evaluate_post_sieve(telemetry) + assert any("post_sieve_cap" in w for w in verdict.warnings) + + def test_evaluate_post_sieve_deny_rule_returns_violation(self, tmp_path: Path) -> None: + """evaluate_post_sieve() deny rule sets allowed=False when threshold is exceeded.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + { + "name": "sieved_hard_cap", + "scope": "telemetry", + "metric": "sieved_tokens", + "threshold": 50, + "action": "deny", + } + ], + } + engine = PolicyEngine(_write_policy(tmp_path, config)) + telemetry = _make_telemetry(raw_tokens=200, sieved_tokens=100) + verdict = engine.evaluate_post_sieve(telemetry) + assert verdict.allowed is False + assert any("sieved_hard_cap" in v for v in verdict.violations) + + def test_falls_back_to_token_estimate_when_telemetry_absent(self, tmp_path: Path) -> None: + """With telemetry=None, raw scope falls back to payload.token_estimate.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + { + "name": "high_estimate", + "scope": "telemetry", + "metric": "raw_tokens", + "threshold": 100, + "action": "warn", + } + ], + } + engine = PolicyEngine(_write_policy(tmp_path, config)) + payload = NormalizedPayload( + source="raw", + content=["small payload"], + metadata={}, + tools=[], + token_estimate=500, + ) + verdict = engine.evaluate(payload, telemetry=None) + assert any("high_estimate" in w for w in verdict.warnings) + + +# --------------------------------------------------------------------------- +# TestGlobalCeiling +# --------------------------------------------------------------------------- + +class TestGlobalCeiling: + def test_deny_when_token_estimate_exceeds_ceiling(self, tmp_path: Path) -> None: + """Global max_token_ceiling triggers a deny verdict when breached.""" + config = {"version": "1.0", "global": {"max_token_ceiling": 100}, "rules": []} + engine = PolicyEngine(_write_policy(tmp_path, config)) + payload = NormalizedPayload( + source="raw", + content=["payload"], + metadata={}, + tools=[], + token_estimate=200, + ) + verdict = engine.evaluate(payload) + assert verdict.allowed is False + assert len(verdict.violations) == 1 + + def test_allow_when_token_estimate_within_ceiling(self, tmp_path: Path) -> None: + """Global ceiling does not fire when token_estimate is within bounds.""" + config = {"version": "1.0", "global": {"max_token_ceiling": 1000}, "rules": []} + engine = PolicyEngine(_write_policy(tmp_path, config)) + payload = NormalizedPayload( + source="raw", + content=["payload"], + metadata={}, + tools=[], + token_estimate=200, + ) + verdict = engine.evaluate(payload) + assert verdict.allowed is True + + def test_zero_ceiling_skips_global_check(self, tmp_path: Path) -> None: + """max_token_ceiling of 0 is treated as disabled (no ceiling enforced).""" + config = {"version": "1.0", "global": {"max_token_ceiling": 0}, "rules": []} + engine = PolicyEngine(_write_policy(tmp_path, config)) + payload = NormalizedPayload( + source="raw", + content=["payload"], + metadata={}, + tools=[], + token_estimate=999999, + ) + verdict = engine.evaluate(payload) + assert verdict.allowed is True + + def test_raises_on_unknown_telemetry_metric(self, tmp_path: Path) -> None: + """A telemetry rule with an unrecognised metric name raises AirlockConfigurationError at init.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + { + "name": "typo_metric", + "scope": "telemetry", + "metric": "raw_token", + "threshold": 1000, + "action": "warn", + } + ], + } + with pytest.raises(AirlockConfigurationError, match="raw_token"): + PolicyEngine(_write_policy(tmp_path, config)) + + def test_raises_on_out_of_bounds_prose_tax_threshold(self, tmp_path: Path) -> None: + """A prose_tax_warning_threshold outside [0.0, 1.0] raises AirlockConfigurationError.""" + config = { + "version": "1.0", + "global": {"prose_tax_warning_threshold": 35.0}, + "rules": [], + } + with pytest.raises(AirlockConfigurationError, match="prose_tax_warning_threshold"): + PolicyEngine(_write_policy(tmp_path, config)) + + def test_policy_rule_fields_is_immutable_tuple(self, tmp_path: Path) -> None: + """PolicyRule.fields is stored as an immutable tuple, not a mutable list.""" + config = { + "version": "1.0", + "global": {}, + "rules": [ + { + "name": "field_rule", + "scope": "fields", + "fields": ["messages.content", "tools.description"], + "pattern": "secret", + "action": "deny", + } + ], + } + engine = PolicyEngine(_write_policy(tmp_path, config)) + rule = engine._rules[0] + assert isinstance(rule.fields, tuple) + with pytest.raises(AttributeError): + rule.fields.append("mutation") # type: ignore[attr-defined] diff --git a/packages/sovereign-airlock/tests/test_receipts.py b/packages/sovereign-airlock/tests/test_receipts.py new file mode 100644 index 0000000..1556b78 --- /dev/null +++ b/packages/sovereign-airlock/tests/test_receipts.py @@ -0,0 +1,157 @@ +"""TDD test suite for sovereign_airlock.receipt — write before implementation.""" + +import logging +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from sovereign_core import ForensicReceipt, SovereignKeyManager +from sovereign_ledger import SovereignLedger, SovereignStorageError + +from sovereign_airlock.receipt import ReceiptBuilder +from sovereign_airlock.telemetry import AirlockTelemetry +from sovereign_sieve import SieveOutput + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def key_manager(tmp_path: Path, sovereign_secret: str) -> SovereignKeyManager: + """Initialised SovereignKeyManager with a fresh keypair in a temp directory.""" + km = SovereignKeyManager(key_dir=str(tmp_path / "keys")) + km.load_or_generate_keypair() + return km + + +@pytest.fixture +def mem_ledger() -> SovereignLedger: + """In-memory SovereignLedger for isolated test runs.""" + ledger = SovereignLedger(":memory:") + yield ledger + ledger.close() + + +@pytest.fixture +def telemetry() -> AirlockTelemetry: + import hashlib + + return AirlockTelemetry( + raw_tokens=120, + sieved_tokens=90, + tax_savings_percentage=25.0, + payload_hash=hashlib.sha256(b"test raw content").hexdigest(), + ) + + +@pytest.fixture +def builder(key_manager: SovereignKeyManager, mem_ledger: SovereignLedger) -> ReceiptBuilder: + return ReceiptBuilder(key_manager=key_manager, ledger=mem_ledger) + + +# --------------------------------------------------------------------------- +# TestReceiptBuilder +# --------------------------------------------------------------------------- + +class TestReceiptBuilder: + def test_build_and_commit_returns_forensic_receipt( + self, builder: ReceiptBuilder, telemetry: AirlockTelemetry + ) -> None: + """build_and_commit returns a populated ForensicReceipt TypedDict.""" + receipt = builder.build_and_commit( + sieved_content="clean payload text", + telemetry=telemetry, + policy_warnings=[], + source="raw", + ) + assert isinstance(receipt, dict) + assert "payload_hash" in receipt + assert "timestamp" in receipt + assert "signature" in receipt + assert "public_key" in receipt + + def test_receipt_metadata_contains_boundary_key( + self, builder: ReceiptBuilder, telemetry: AirlockTelemetry + ) -> None: + """Receipt metadata identifies sovereign-sdk-airlock as the producing boundary.""" + receipt = builder.build_and_commit("content", telemetry, [], "openai") + assert receipt["metadata"]["boundary"] == "sovereign-sdk-airlock" + + def test_receipt_metadata_contains_prose_tax_summary( + self, builder: ReceiptBuilder, telemetry: AirlockTelemetry + ) -> None: + """prose_tax_summary in metadata carries the three telemetry metrics.""" + receipt = builder.build_and_commit("content", telemetry, [], "openai") + pts = receipt["metadata"]["prose_tax_summary"] + assert pts["raw_token_count"] == telemetry.raw_tokens + assert pts["optimized_token_count"] == telemetry.sieved_tokens + assert pts["tax_savings_percentage"] == telemetry.tax_savings_percentage + + def test_receipt_metadata_includes_source_transport( + self, builder: ReceiptBuilder, telemetry: AirlockTelemetry + ) -> None: + """source_transport in metadata records the normalised payload origin.""" + receipt = builder.build_and_commit("content", telemetry, [], "anthropic") + assert receipt["metadata"]["source_transport"] == "anthropic" + + def test_policy_warnings_included_when_non_empty( + self, builder: ReceiptBuilder, telemetry: AirlockTelemetry + ) -> None: + """Non-empty policy_warnings list is sealed inside receipt metadata.""" + warnings = ["Rule 'excessive_context' matched.", "Rule 'guard_internal' matched."] + receipt = builder.build_and_commit("content", telemetry, warnings, "raw") + assert receipt["metadata"]["policy_warnings"] == warnings + + def test_policy_warnings_absent_when_empty( + self, builder: ReceiptBuilder, telemetry: AirlockTelemetry + ) -> None: + """Empty policy_warnings list does not add the key to receipt metadata.""" + receipt = builder.build_and_commit("content", telemetry, [], "raw") + assert "policy_warnings" not in receipt["metadata"] + + def test_receipt_is_cryptographically_verifiable( + self, builder: ReceiptBuilder, key_manager: SovereignKeyManager, telemetry: AirlockTelemetry + ) -> None: + """Receipt produced by build_and_commit verifies successfully against the signing key.""" + sieved = "verified sieved content" + receipt = builder.build_and_commit(sieved, telemetry, [], "raw") + payload_dict = {"content": sieved} + assert SovereignKeyManager.verify_receipt( + receipt, payload_dict, expected_public_key=key_manager.public_key + ) + + def test_ledger_commit_called_when_ledger_provided( + self, builder: ReceiptBuilder, mem_ledger: SovereignLedger, telemetry: AirlockTelemetry + ) -> None: + """Receipt is appended to the ledger when a ledger instance is configured.""" + receipt = builder.build_and_commit("content", telemetry, [], "raw") + assert mem_ledger.verify_ledger_integrity(expected_tip_hash=receipt["payload_hash"]) + + def test_ledger_write_failure_is_non_fatal( + self, + key_manager: SovereignKeyManager, + telemetry: AirlockTelemetry, + caplog, + ) -> None: + """A ledger write failure does not raise; warning is emitted and receipt returned.""" + failing_ledger = MagicMock(spec=SovereignLedger) + failing_ledger.append_receipt.side_effect = SovereignStorageError("simulated ledger failure") + builder = ReceiptBuilder(key_manager=key_manager, ledger=failing_ledger) + + with caplog.at_level(logging.WARNING, logger="sovereign_airlock.receipt"): + receipt = builder.build_and_commit("content", telemetry, [], "raw") + + assert receipt is not None + assert "payload_hash" in receipt + assert any("ledger" in record.message.lower() for record in caplog.records) + + def test_no_ledger_commit_when_ledger_is_none( + self, key_manager: SovereignKeyManager, telemetry: AirlockTelemetry + ) -> None: + """build_and_commit succeeds and returns a receipt when ledger=None.""" + builder = ReceiptBuilder(key_manager=key_manager, ledger=None) + receipt = builder.build_and_commit("content", telemetry, [], "raw") + assert receipt is not None + assert "payload_hash" in receipt diff --git a/packages/sovereign-airlock/tests/test_telemetry.py b/packages/sovereign-airlock/tests/test_telemetry.py new file mode 100644 index 0000000..5a54bee --- /dev/null +++ b/packages/sovereign-airlock/tests/test_telemetry.py @@ -0,0 +1,126 @@ +"""TDD test suite for sovereign_airlock.telemetry — write before implementation.""" + +import hashlib + +import pytest + +from sovereign_airlock.telemetry import AirlockTelemetry +from sovereign_sieve import SieveOutput + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_sieve_output( + text: str = "clean text", + raw_token_count: int = 100, + optimized_token_count: int = 80, + tax_savings_percentage: float = 20.0, +) -> SieveOutput: + return SieveOutput( + text=text, + raw_token_count=raw_token_count, + optimized_token_count=optimized_token_count, + tax_savings_percentage=tax_savings_percentage, + ) + + +# --------------------------------------------------------------------------- +# TestAirlockTelemetry +# --------------------------------------------------------------------------- + +class TestAirlockTelemetry: + def test_raw_tokens_from_sieve_output(self) -> None: + """raw_tokens is sourced directly from SieveOutput.raw_token_count.""" + output = _make_sieve_output(raw_token_count=200) + telemetry = AirlockTelemetry.from_sieve_output(output, "raw input text") + assert telemetry.raw_tokens == 200 + + def test_sieved_tokens_from_sieve_output(self) -> None: + """sieved_tokens is sourced from SieveOutput.optimized_token_count.""" + output = _make_sieve_output(optimized_token_count=150) + telemetry = AirlockTelemetry.from_sieve_output(output, "raw input text") + assert telemetry.sieved_tokens == 150 + + def test_tax_savings_percentage_calculated_correctly(self) -> None: + """tax_savings_percentage is re-derived from raw and sieved token counts.""" + output = _make_sieve_output(raw_token_count=100, optimized_token_count=75) + telemetry = AirlockTelemetry.from_sieve_output(output, "content") + assert telemetry.tax_savings_percentage == pytest.approx(25.0, abs=1e-3) + + def test_zero_raw_tokens_defaults_to_zero_savings(self) -> None: + """Explicit ZeroDivisionError guard: raw_tokens==0 → tax_savings_percentage=0.0.""" + output = _make_sieve_output(raw_token_count=0, optimized_token_count=0) + telemetry = AirlockTelemetry.from_sieve_output(output, "") + assert telemetry.tax_savings_percentage == 0.0 + + def test_payload_hash_is_sha256_of_raw_content(self) -> None: + """payload_hash equals the SHA-256 hex digest of the raw content string.""" + raw = "the raw input before sieving" + output = _make_sieve_output() + telemetry = AirlockTelemetry.from_sieve_output(output, raw) + expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() + assert telemetry.payload_hash == expected + + def test_payload_hash_uniqueness_across_distinct_content(self) -> None: + """Different raw content strings produce different payload hashes.""" + output = _make_sieve_output() + t1 = AirlockTelemetry.from_sieve_output(output, "content A") + t2 = AirlockTelemetry.from_sieve_output(output, "content B") + assert t1.payload_hash != t2.payload_hash + + def test_immutable_frozen_dataclass(self) -> None: + """AirlockTelemetry is frozen; field assignment raises FrozenInstanceError.""" + from dataclasses import FrozenInstanceError + + output = _make_sieve_output() + telemetry = AirlockTelemetry.from_sieve_output(output, "content") + with pytest.raises(FrozenInstanceError): + telemetry.raw_tokens = 999 # type: ignore[misc] + + def test_tax_savings_percentage_rounds_to_four_decimal_places(self) -> None: + """tax_savings_percentage is rounded to exactly 4 decimal places.""" + # 1/3 savings → 33.333...% — must round to 4 dp + output = _make_sieve_output(raw_token_count=3, optimized_token_count=2) + telemetry = AirlockTelemetry.from_sieve_output(output, "abc") + assert telemetry.tax_savings_percentage == round(100.0 / 3.0, 4) + + def test_payload_hash_is_64_hex_characters(self) -> None: + """SHA-256 output is always 64 lowercase hex characters.""" + output = _make_sieve_output() + telemetry = AirlockTelemetry.from_sieve_output(output, "any content") + assert len(telemetry.payload_hash) == 64 + assert all(c in "0123456789abcdef" for c in telemetry.payload_hash) + + def test_negative_savings_clamped_to_zero(self) -> None: + """When sieved_tokens > raw_tokens (content expansion), tax_savings_percentage is clamped to 0.0.""" + output = _make_sieve_output(raw_token_count=50, optimized_token_count=75) + telemetry = AirlockTelemetry.from_sieve_output(output, "short input that expanded") + assert telemetry.tax_savings_percentage == 0.0 + + def test_over_optimized_savings_clamped_to_hundred(self) -> None: + """When optimized_token_count is negative (impossible expansion inversion), tax_savings_percentage is clamped to 100.0.""" + output = _make_sieve_output(raw_token_count=100, optimized_token_count=-10) + telemetry = AirlockTelemetry.from_sieve_output(output, "impossible sieve output") + assert telemetry.tax_savings_percentage == 100.0 + + def test_full_sieve_round_trip_via_sieve_with_metrics(self) -> None: + """AirlockTelemetry built from a live sieve_with_metrics pass is self-consistent.""" + from sovereign_sieve import sieve_with_metrics + + raw = "Hello! Please just run the analysis pipeline." + live_output = sieve_with_metrics(raw) + telemetry = AirlockTelemetry.from_sieve_output(live_output, raw) + assert telemetry.raw_tokens == live_output.raw_token_count + assert telemetry.sieved_tokens == live_output.optimized_token_count + if live_output.raw_token_count == 0: + assert telemetry.tax_savings_percentage == 0.0 + else: + expected_pct = round( + (live_output.raw_token_count - live_output.optimized_token_count) + / live_output.raw_token_count + * 100.0, + 4, + ) + assert telemetry.tax_savings_percentage == expected_pct diff --git a/pyproject.toml b/pyproject.toml index 4c669f2..44c294e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,10 +24,12 @@ dev-dependencies = [ "sovereign-sdk-runtime", "sovereign-sdk-sensor", "sovereign-sdk-sieve", + "sovereign-sdk-airlock", ] # This block tells uv to resolve these dev-dependencies locally [tool.uv.sources] +sovereign-sdk-airlock = { workspace = true } sovereign-sdk-core = { workspace = true } sovereign-sdk-edge = { workspace = true } sovereign-sdk-fastapi = { workspace = true } diff --git a/uv.lock b/uv.lock index 6f8a05a..07e5d97 100644 --- a/uv.lock +++ b/uv.lock @@ -4,6 +4,7 @@ requires-python = ">=3.12" [manifest] members = [ + "sovereign-sdk-airlock", "sovereign-sdk-core", "sovereign-sdk-edge", "sovereign-sdk-fastapi", @@ -420,6 +421,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "sovereign-sdk-airlock" +version = "1.4.0" +source = { editable = "packages/sovereign-airlock" } +dependencies = [ + { name = "pyyaml" }, + { name = "sovereign-sdk-core" }, + { name = "sovereign-sdk-ledger" }, + { name = "sovereign-sdk-sieve" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyyaml", specifier = ">=6.0" }, + { name = "sovereign-sdk-core", editable = "packages/sovereign-core" }, + { name = "sovereign-sdk-ledger", editable = "packages/sovereign-ledger" }, + { name = "sovereign-sdk-sieve", editable = "packages/sovereign-sieve" }, +] + [[package]] name = "sovereign-sdk-core" version = "1.3.0" @@ -517,6 +583,7 @@ dev = [ { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "sovereign-sdk-airlock" }, { name = "sovereign-sdk-core" }, { name = "sovereign-sdk-edge" }, { name = "sovereign-sdk-fastapi" }, @@ -534,6 +601,7 @@ dev = [ { name = "httpx", specifier = ">=0.24.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "sovereign-sdk-airlock", editable = "packages/sovereign-airlock" }, { name = "sovereign-sdk-core", editable = "packages/sovereign-core" }, { name = "sovereign-sdk-edge", editable = "packages/sovereign-edge" }, { name = "sovereign-sdk-fastapi", editable = "packages/sovereign-fastapi" },