Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`sovereign-sdk-runtime`, and `sovereign-sdk-edge` internal dependency declarations
updated to reference the new `sovereign-sdk-*` distribution names at `>=1.3.0`.

## [Unreleased]
## [1.4.0] — 2026-07-05

### Added

Expand Down Expand Up @@ -135,6 +135,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`from_sieve_output()`. Content expansion (sieved > raw) clamps to `0.0`; impossible
inversion (negative `optimized_token_count`) clamps to `100.0`.

- **`AirlockPolicyViolation.warnings` diagnostic attribute** (`exception.py`,
`boundary.py`): `AirlockPolicyViolation.__init__` now accepts an optional
`warnings: list[str] | None` parameter; accumulated pre-sieve `warn`-action messages
are stored as `self.warnings: list[str]`. When `AirlockBoundary.process()` raises
`AirlockPolicyViolation` on a post-sieve deny verdict, it passes `verdict.warnings`
so callers can programmatically inspect pre-sieve diagnostic context via `exc.warnings`
without losing it at the exception boundary.

- **`AirlockConfigurationError` de-duplication** (`policy.py`): Since
`AirlockConfigurationError` extends `ValueError`, clean validation errors raised inside
`_parse_rule` were being caught by the broad `except (KeyError, TypeError, ValueError)`
handler in `PolicyEngine.__init__` and re-wrapped with a redundant "Invalid rule
definition" prefix. An explicit `except AirlockConfigurationError: raise` guard now
lets those errors surface natively with their original message intact.

- **SPDX license metadata** (`pyproject.toml`): `project.license` migrated from
deprecated TOML table form (`{ text = "MIT" }`) to SPDX string literal (`"MIT"`).
Deprecated `License :: OSI Approved :: MIT License` classifier removed. Eliminates
two `SetuptoolsDeprecationWarning` emissions that become hard build errors after
2027-02-18.

- **PEP 517 build validation**: `uv build --package sovereign-sdk-airlock` clean-room
pass produces `sovereign_sdk_airlock-1.4.0.tar.gz` and
`sovereign_sdk_airlock-1.4.0-py3-none-any.whl` with no editable-path or direct-url
leakage. Wheel METADATA confirmed: all `Requires-Dist` entries, `Description-Content-Type:
text/markdown`, and full `README.md` long-description body (3,401 bytes, UTF-8).

- **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`
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ try:
# result.policy_warnings — non-fatal warn-rule messages
except AirlockPolicyViolation as exc:
# Payload blocked by a deny rule — do not transmit
# exc.warnings carries any pre-sieve warn-rule messages accumulated before the deny
raise
```

Expand Down
17 changes: 14 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1300,11 +1300,22 @@ result = await boundary.process(normalize_openai(request))
`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
invariant violations respectively; `AirlockPolicyViolation.__init__` accepts optional
`warnings: list[str] | None` (default `[]`) stored as `self.warnings`, preserving
pre-sieve `warn`-action diagnostic context when a post-sieve deny fires
* [x] `AirlockConfigurationError` de-duplication (`policy.py`) — explicit
`except AirlockConfigurationError: raise` guard in `PolicyEngine.__init__` prevents
clean boot-time validation errors raised by `_parse_rule` from being caught and
re-wrapped by the broad `except (KeyError, TypeError, ValueError)` handler (since
`AirlockConfigurationError` extends `ValueError`)
* [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] 83-case test suite across four files (`TestAirlockTelemetry`: 12;
`sovereign-sdk-ledger>=1.3.0`, `sovereign-sdk-sieve>=1.3.0`, `pyyaml>=6.0`;
`license` migrated from deprecated TOML table form to SPDX string literal (`"MIT"`);
deprecated `License :: OSI Approved :: MIT License` classifier removed; PEP 517 build
validated via `uv build --package sovereign-sdk-airlock` — clean sdist and
pure-Python wheel produced with no editable-path or direct-url leakage
* [x] 84-case test suite across four files (`TestAirlockTelemetry`: 12;
`TestPolicyLoading` + `TestRawScopeEvaluation` + `TestFieldsScopeEvaluation` +
`TestTelemetryScopeEvaluation` + `TestGlobalCeiling`: 35; `TestReceiptBuilder`: 11;
`TestAirlockBoundaryHappyPath` + `TestAirlockBoundaryPolicyDenial` +
Expand Down
14 changes: 14 additions & 0 deletions packages/sovereign-airlock/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ rules:
| `warn` | Appends to `AirlockResult.policy_warnings`; transmission continues |
| `deny` | Raises `AirlockPolicyViolation`; payload blocked |

### Exception Inspection

When a post-sieve `deny` rule fires, any `warn`-action messages accumulated during the
pre-sieve pass are preserved on the exception for diagnostic inspection:

```python
try:
result = await boundary.process(payload)
except AirlockPolicyViolation as exc:
print(exc) # deny violation message
print(exc.warnings) # pre-sieve warn messages (list[str], may be empty)
raise
```

---

## Transport Normalisation
Expand Down
3 changes: 1 addition & 2 deletions packages/sovereign-airlock/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,14 @@ 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" }
license = "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",
Expand Down
10 changes: 8 additions & 2 deletions packages/sovereign-airlock/src/sovereign_airlock/boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ async def process(self, payload: NormalizedPayload) -> AirlockResult:
# Component B: Policy evaluation (pre-sieve)
verdict: PolicyVerdict = self._policy.evaluate(payload)
if not verdict.allowed:
raise AirlockPolicyViolation("; ".join(verdict.violations))
raise AirlockPolicyViolation(
"; ".join(verdict.violations),
warnings=verdict.warnings,
)

# Component C: Sieve convergence pass (offloaded to thread pool — CPU-bound)
raw_content: str = " ".join(payload.content)
Expand All @@ -143,7 +146,10 @@ async def process(self, payload: NormalizedPayload) -> AirlockResult:
# 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))
raise AirlockPolicyViolation(
"; ".join(post_verdict.violations),
warnings=verdict.warnings,
)
Comment on lines 147 to +152

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Post-sieve deny drops concurrent post-sieve warnings. When a post-sieve deny fires, the code passes only verdict.warnings (pre-sieve) to the exception, silently discarding any post_verdict.warnings accumulated during the same evaluate_post_sieve call. This includes prose-tax threshold warnings and any warn-action telemetry rules that fired alongside the deny. A caller inspecting exc.warnings for diagnostics will be missing half the context in this scenario.

Suggested change
post_verdict = self._policy.evaluate_post_sieve(telemetry)
if not post_verdict.allowed:
raise AirlockPolicyViolation("; ".join(post_verdict.violations))
raise AirlockPolicyViolation(
"; ".join(post_verdict.violations),
warnings=verdict.warnings,
)
post_verdict = self._policy.evaluate_post_sieve(telemetry)
if not post_verdict.allowed:
raise AirlockPolicyViolation(
"; ".join(post_verdict.violations),
warnings=verdict.warnings + post_verdict.warnings,
)

verdict.warnings.extend(post_verdict.warnings)

# Component D: Evidence generation — non-fatal on any failure
Expand Down
17 changes: 17 additions & 0 deletions packages/sovereign-airlock/src/sovereign_airlock/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,25 @@ class AirlockPolicyViolation(RuntimeError):

:param message: Human-readable description of the policy rules that were violated.
:type message: str
:param warnings: Non-fatal ``warn``-action messages accumulated prior to the deny,
preserved here for caller diagnostics. Empty list when none were collected.
:type warnings: list[str] | None
"""

warnings: list[str]

def __init__(self, message: str, warnings: list[str] | None = None) -> None:
"""
Initialise the violation exception with violation message and optional prior warnings.

:param message: Human-readable description of the policy rules that were violated.
:type message: str
:param warnings: Non-fatal ``warn``-action messages accumulated before the deny verdict.
:type warnings: list[str] | None
"""
super().__init__(message)
self.warnings = list(warnings) if warnings is not None else []


class AirlockConfigurationError(ValueError):
"""Raised when a policy YAML configuration file is missing, malformed, or structurally invalid.
Expand Down
2 changes: 2 additions & 0 deletions packages/sovereign-airlock/src/sovereign_airlock/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ def __init__(self, config_path: str | Path) -> None:
for rule_def in raw.get("rules") or []:
try:
self._rules.append(self._parse_rule(rule_def))
except AirlockConfigurationError:
raise
except (KeyError, TypeError, ValueError) as exc:
raise AirlockConfigurationError(
f"Invalid rule definition {rule_def!r}: {exc}"
Expand Down
11 changes: 11 additions & 0 deletions packages/sovereign-airlock/tests/test_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ async def test_deny_does_not_commit_to_ledger(
# Chain is intact but empty — no receipt was committed
assert mem_ledger.verify_ledger_integrity(expected_tip_hash=None)

async def test_pre_sieve_deny_preserves_accumulated_warnings(
self, airlock: AirlockBoundary
) -> None:
"""Pre-sieve deny carries concurrent warn-rule messages via exc.warnings."""
payload = normalize_raw(
"internal.sovereign.local config: -----BEGIN PRIVATE KEY-----"
)
with pytest.raises(AirlockPolicyViolation) as exc_info:
await airlock.process(payload)
assert any("guard_internal_namespaces" in w for w in exc_info.value.warnings)

async def test_post_sieve_telemetry_deny_raises_policy_violation(
self, tmp_path: Path, sovereign_secret: str
) -> None:
Expand Down
Loading