Skip to content

Commit 16407a2

Browse files
committed
fix: seal compiled policy snapshots
1 parent ec3ff15 commit 16407a2

11 files changed

Lines changed: 316 additions & 18 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ experimental and fixture classifications.
88

99
## Unreleased
1010

11+
- Adds a private complete-payload seal to compiled policy packs and requires a
12+
stable, detached canonical snapshot before policy evaluation, pack digesting
13+
or enforcement admission. Post-compilation mutation of nested condition,
14+
metadata, obligation or constraint values now fails closed with the existing
15+
`invalid_compiled_policy_pack` reason without changing public records,
16+
signatures or digest payloads.
1117
- Closes the v0.1 policy pack and rule field inventories while preserving only
1218
the intentional pack `id`, rule `id`, and rule `decision` compatibility
1319
aliases. The authoring schema and runtime compiler now reject ambiguous dual

docs/POLICY_ENGINE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ Compiled rules are sorted by `priority` (lower first).
104104
The compiler performs exact deterministic analysis only; it does not attempt
105105
partial-overlap reasoning, subsumption or SAT solving.
106106

107+
Each `CompiledPolicyPack` returned by the compiler records a private,
108+
process-local seal over its complete compiled payload. Policy evaluation,
109+
policy-pack digesting and enforcement admission compare the current payload to
110+
that seal, recompile a detached canonical snapshot and reject instability with
111+
`invalid_compiled_policy_pack`. This catches post-compilation changes inside
112+
mutable condition values, pack/control metadata and constraint values. The
113+
seal is an in-process consistency check, not a signature, authenticity proof
114+
or defense against a malicious host that deliberately bypasses GovEngine.
115+
107116
### Typed conditions (`schema_version: v1`)
108117

109118
Policy pack v1 replaces the implicit equality map with an explicit condition

docs/SECURITY_GUARANTEES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ do not apply when an in-process host bypasses or modifies GovEngine.
1010
| --- | --- | --- |
1111
| Strict bounded input | Shared JSON byte/depth/node/collection/string limits, duplicate-key and non-finite rejection, recursive forbidden keys | `test_api_hardening.py`, `test_security_properties.py`, conformance corpus |
1212
| Deterministic policy | Typed closed operators, strict operand types, canonical condition order, bounded compilation, deny-first evaluation | `test_policy_conditions.py`, `test_policy_v1_stability.py` |
13+
| Compiled policy snapshot integrity | Private complete-payload seal plus detached canonical recompilation before evaluation, digesting and enforcement admission | `test_policy_conditions.py`, `test_policy_engine.py`, `test_policy_enforcement.py` |
1314
| Active policy binding | Digest/epoch/issuer/status/validity checked against host activation port; authorization cannot outlive the activation observed at issuance | `test_governance_decision.py` |
1415
| Approval is independent | Exact subject binding, trust policy, validity, revocation and host signature verification | `test_governance_request.py`, `test_governance_decision.py` |
1516
| Scope is not self-authorized | Requested destination is compared with an independent scope policy | `test_scope_capabilities.py`, corpus |
@@ -25,7 +26,7 @@ do not apply when an in-process host bypasses or modifies GovEngine.
2526

2627
| Binding | Complete payload owner | GovEngine behavior | Security meaning |
2728
| --- | --- | --- | --- |
28-
| Policy pack digest | GovEngine | Recomputes canonical GovEngine digest | Exact compiled policy content, not issuer identity |
29+
| Policy pack digest | GovEngine | Validates a sealed detached snapshot, then recomputes the unchanged canonical GovEngine digest | Exact compiled policy content, not issuer identity or producer authenticity |
2930
| Execution facts digest | GovEngine bounded projection | Recomputes | Exact bounded policy-evaluation facts |
3031
| Requested scope digest | GovEngine bounded projection | Recomputes | Exact requested namespace/destination metadata |
3132
| Scope policy digest | GovEngine | Recomputes | Exact independent allow policy |

govengine/policy/compiler/__init__.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from dataclasses import dataclass, field
44
from datetime import timezone
5+
from hmac import compare_digest
56
import math
67
import re
78
from typing import Any, Mapping
@@ -271,6 +272,9 @@ class CompiledPolicyPack:
271272
expires_at: str = ''
272273
supersedes: tuple[str, ...] = field(default_factory=tuple)
273274

275+
def __post_init__(self) -> None:
276+
object.__setattr__(self, '_integrity_seal', _compiled_policy_pack_seal(self))
277+
274278
def as_dict(self) -> dict[str, Any]:
275279
payload: dict[str, Any] = {
276280
'policy_id': self.policy_id,
@@ -435,6 +439,53 @@ def compile_policy_pack(policy_pack: Mapping[str, Any]) -> CompileResult:
435439
return PolicyCompiler().compile(policy_pack)
436440

437441

442+
def _compiled_policy_pack_seal(policy_pack: CompiledPolicyPack) -> str:
443+
return govengine_record_digest(
444+
policy_pack,
445+
record_type='govengine.policy.compiler.CompiledPolicyPackIntegritySeal',
446+
)
447+
448+
449+
def _validated_compiled_policy_pack_snapshot(
450+
policy_pack: CompiledPolicyPack,
451+
) -> CompiledPolicyPack:
452+
if not isinstance(policy_pack, CompiledPolicyPack):
453+
raise GovApiError('invalid_compiled_policy_pack')
454+
try:
455+
initial_seal = getattr(policy_pack, '_integrity_seal', None)
456+
if not isinstance(initial_seal, str):
457+
raise GovApiError('invalid_compiled_policy_pack')
458+
result = PolicyCompiler().compile(policy_pack.as_dict())
459+
if not result.ok or result.policy_pack is None:
460+
raise GovApiError('invalid_compiled_policy_pack')
461+
snapshot = result.policy_pack
462+
snapshot_seal = getattr(snapshot, '_integrity_seal', None)
463+
if (
464+
not isinstance(snapshot_seal, str)
465+
or not compare_digest(
466+
snapshot_seal,
467+
_compiled_policy_pack_seal(snapshot),
468+
)
469+
):
470+
raise GovApiError('invalid_compiled_policy_pack')
471+
final_stored_seal = getattr(policy_pack, '_integrity_seal', None)
472+
final_payload_seal = _compiled_policy_pack_seal(policy_pack)
473+
if (
474+
snapshot != policy_pack
475+
or not isinstance(final_stored_seal, str)
476+
or not compare_digest(initial_seal, final_stored_seal)
477+
or not compare_digest(initial_seal, final_payload_seal)
478+
):
479+
raise GovApiError('invalid_compiled_policy_pack')
480+
except GovApiError as exc:
481+
if exc.reason_code == 'invalid_compiled_policy_pack':
482+
raise
483+
raise GovApiError('invalid_compiled_policy_pack') from exc
484+
except (AttributeError, RecursionError, TypeError, ValueError) as exc:
485+
raise GovApiError('invalid_compiled_policy_pack') from exc
486+
return snapshot
487+
488+
438489
def _reject_conflicts(rules: tuple[PolicyRule, ...]) -> None:
439490
rule_ids: set[str] = set()
440491
seen: dict[str, str] = {}

govengine/policy/enforcement.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
admission_decision_from_host_gate,
1212
validate_admission_decision,
1313
)
14-
from govengine.policy.compiler import CompiledPolicyPack
14+
from govengine.policy.compiler import (
15+
CompiledPolicyPack,
16+
_validated_compiled_policy_pack_snapshot,
17+
)
1518
from govengine.policy.model import PolicyVerdict, validate_policy_verdict
1619
from govengine.signing import govengine_record_digest
1720

@@ -151,8 +154,7 @@ def as_dict(self) -> dict[str, Any]:
151154

152155

153156
def policy_pack_digest(policy_pack: CompiledPolicyPack) -> str:
154-
if not isinstance(policy_pack, CompiledPolicyPack):
155-
raise GovApiError("invalid_compiled_policy_pack")
157+
policy_pack = _validated_compiled_policy_pack_snapshot(policy_pack)
156158
return govengine_record_digest(
157159
policy_pack,
158160
record_type="govengine.policy.compiler.CompiledPolicyPack",
@@ -317,6 +319,7 @@ def admit_policy_execution(
317319
policy_pack: CompiledPolicyPack,
318320
verdict: Mapping[str, Any] | PolicyVerdict,
319321
) -> PolicyEnforcementPlan:
322+
policy_pack = _validated_compiled_policy_pack_snapshot(policy_pack)
320323
checked = validate_policy_verdict(verdict)
321324
blockers = list(checked.blockers)
322325
reason_code = checked.reason_code or checked.decision

govengine/policy/explain.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
from dataclasses import dataclass, field, replace
44
from typing import Any, Mapping
55

6-
from govengine.api import GovApiError
7-
from govengine.policy.compiler import CompiledPolicyPack, PolicyRule
6+
from govengine.policy.compiler import (
7+
CompiledPolicyPack,
8+
PolicyRule,
9+
_validated_compiled_policy_pack_snapshot,
10+
)
811
from govengine.policy.enforcement import (
912
SUPPORTED_POLICY_CONSTRAINTS,
1013
SUPPORTED_POLICY_OBLIGATIONS,
@@ -100,8 +103,7 @@ def explain_policy_evaluation(
100103
context: Mapping[str, Any] | None = None,
101104
) -> PolicyEvaluationExplanation:
102105
checked_request = validate_policy_request(request)
103-
if not isinstance(policy_pack, CompiledPolicyPack):
104-
raise GovApiError("invalid_compiled_policy_pack")
106+
policy_pack = _validated_compiled_policy_pack_snapshot(policy_pack)
105107
runtime_context = dict(context or {})
106108
verdict = PolicyEngine().evaluate(
107109
checked_request,

govengine/policy/runtime/__init__.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
from typing import Any, Mapping
44

55
from govengine.api import GovApiError
6-
from govengine.policy.compiler import CompiledPolicyPack, PolicyCondition, PolicyRule
6+
from govengine.policy.compiler import (
7+
CompiledPolicyPack,
8+
PolicyCondition,
9+
PolicyRule,
10+
_validated_compiled_policy_pack_snapshot,
11+
)
712
from govengine.policy.model import PolicyRequest, PolicyVerdict, validate_policy_request, validate_policy_verdict
813

914

@@ -21,8 +26,7 @@ def evaluate(
2126
context: Mapping[str, Any] | None = None,
2227
) -> PolicyVerdict:
2328
checked_request = validate_policy_request(request)
24-
if not isinstance(policy_pack, CompiledPolicyPack):
25-
raise GovApiError('invalid_compiled_policy_pack')
29+
policy_pack = _validated_compiled_policy_pack_snapshot(policy_pack)
2630
runtime_context = dict(context or {})
2731

2832
invariant = self._evaluate_invariants(checked_request, policy_pack)

tests/test_governance_request.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,15 @@ def _base_request_mapping() -> dict[str, Any]:
209209

210210
def _request_mapping_for_policy_pack(
211211
policy_pack: CompiledPolicyPack,
212+
*,
213+
rebound_policy_pack_digest: str | None = None,
212214
) -> dict[str, Any]:
213215
request = _base_request_mapping()
214-
compiled_policy_digest = policy_pack_digest(policy_pack)
216+
compiled_policy_digest = (
217+
rebound_policy_pack_digest
218+
if rebound_policy_pack_digest is not None
219+
else policy_pack_digest(policy_pack)
220+
)
215221
scope_policy_payload = dict(request['scope_policy_binding'])
216222
scope_policy_payload['policy_pack_digest'] = compiled_policy_digest
217223
scope_policy = ScopePolicyBinding.from_mapping(scope_policy_payload)
@@ -222,6 +228,15 @@ def _request_mapping_for_policy_pack(
222228
return request
223229

224230

231+
def _raw_policy_pack_digest_for_mutation_fixture(
232+
policy_pack: CompiledPolicyPack,
233+
) -> str:
234+
return govengine_record_digest(
235+
policy_pack,
236+
record_type='govengine.policy.compiler.CompiledPolicyPack',
237+
)
238+
239+
225240
def _request_mapping_with_approval(
226241
*,
227242
attestation_patch: Mapping[str, Any] | None = None,
@@ -283,7 +298,14 @@ def test_governance_request_rejects_mutated_typed_policy_pack_metadata() -> None
283298
policy_pack = _compiled_policy()
284299
assert isinstance(policy_pack.metadata, dict)
285300
policy_pack.metadata['password'] = 'REDACTED-FIXTURE'
286-
request = _request_mapping_for_policy_pack(policy_pack)
301+
with pytest.raises(GovApiError, match='invalid_compiled_policy_pack'):
302+
policy_pack_digest(policy_pack)
303+
request = _request_mapping_for_policy_pack(
304+
policy_pack,
305+
rebound_policy_pack_digest=_raw_policy_pack_digest_for_mutation_fixture(
306+
policy_pack
307+
),
308+
)
287309

288310
with pytest.raises(GovApiError, match='forbidden_policy_metadata'):
289311
GovernanceRequest.from_mapping(request)
@@ -295,7 +317,11 @@ def test_direct_governance_request_rejects_mutated_typed_policy_pack_metadata()
295317
)
296318
assert isinstance(request.policy_pack.metadata, dict)
297319
request.policy_pack.metadata['password'] = 'REDACTED-FIXTURE'
298-
compiled_policy_digest = policy_pack_digest(request.policy_pack)
320+
with pytest.raises(GovApiError, match='invalid_compiled_policy_pack'):
321+
policy_pack_digest(request.policy_pack)
322+
compiled_policy_digest = _raw_policy_pack_digest_for_mutation_fixture(
323+
request.policy_pack
324+
)
299325
scope_policy_payload = request.scope_policy_binding.as_dict()
300326
scope_policy_payload['policy_pack_digest'] = compiled_policy_digest
301327
scope_policy = ScopePolicyBinding.from_mapping(scope_policy_payload)
@@ -314,7 +340,11 @@ def test_subject_digest_rejects_mutated_typed_policy_pack_metadata() -> None:
314340
request = GovernanceRequest.from_mapping(_base_request_mapping())
315341
assert isinstance(request.policy_pack.metadata, dict)
316342
request.policy_pack.metadata['password'] = 'REDACTED-FIXTURE'
317-
compiled_policy_digest = policy_pack_digest(request.policy_pack)
343+
with pytest.raises(GovApiError, match='invalid_compiled_policy_pack'):
344+
policy_pack_digest(request.policy_pack)
345+
compiled_policy_digest = _raw_policy_pack_digest_for_mutation_fixture(
346+
request.policy_pack
347+
)
318348
scope_policy_payload = request.scope_policy_binding.as_dict()
319349
scope_policy_payload['policy_pack_digest'] = compiled_policy_digest
320350
scope_policy = ScopePolicyBinding.from_mapping(scope_policy_payload)
@@ -378,7 +408,12 @@ def test_safe_typed_policy_pack_matches_mapping_request(
378408

379409
def test_governance_request_rejects_directly_constructed_invalid_typed_pack() -> None:
380410
policy_pack = replace(_compiled_v1_policy(), issuer_ref='')
381-
request = _request_mapping_for_policy_pack(policy_pack)
411+
request = _request_mapping_for_policy_pack(
412+
policy_pack,
413+
rebound_policy_pack_digest=_raw_policy_pack_digest_for_mutation_fixture(
414+
policy_pack
415+
),
416+
)
382417

383418
with pytest.raises(GovApiError, match='missing_policy_issuer_ref'):
384419
GovernanceRequest.from_mapping(request)

tests/test_policy_conditions.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import pytest
44

5-
from govengine import PolicyCompiler, PolicyEngine, explain_policy_evaluation
5+
from govengine import (
6+
PolicyCompiler,
7+
PolicyEngine,
8+
evaluate_policy,
9+
explain_policy_evaluation,
10+
)
611
from govengine.api import GovApiError
712
from govengine.policy.compiler import (
813
_POLICY_CONDITION_V1_FIELDS,
@@ -176,6 +181,27 @@ def test_typed_policy_conditions_are_canonical_and_round_trip() -> None:
176181
]
177182

178183

184+
def test_runtime_rejects_mutated_nested_compiled_condition_value() -> None:
185+
pack = _compile(
186+
{
187+
'path': 'action.labels',
188+
'operator': 'in',
189+
'value': [{'scope': {'name': 'read'}}],
190+
}
191+
)
192+
value = pack.rules[0].conditions[0].value
193+
assert isinstance(value, list)
194+
first = value[0]
195+
assert isinstance(first, dict)
196+
scope = first['scope']
197+
assert isinstance(scope, dict)
198+
scope['name'] = 'write'
199+
200+
for consumer in (PolicyEngine().evaluate, evaluate_policy):
201+
with pytest.raises(GovApiError, match='invalid_compiled_policy_pack'):
202+
consumer(_request(), pack)
203+
204+
179205
def test_legacy_equality_map_compiles_to_typed_ast_without_wire_drift() -> None:
180206
result = PolicyCompiler().compile(
181207
{

0 commit comments

Comments
 (0)