hapax-agentgov 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentgov/__init__.py +104 -0
- agentgov/agent_governor.py +129 -0
- agentgov/carrier.py +138 -0
- agentgov/consent.py +316 -0
- agentgov/consent_label.py +56 -0
- agentgov/governor.py +142 -0
- agentgov/hooks.py +227 -0
- agentgov/labeled.py +81 -0
- agentgov/primitives.py +150 -0
- agentgov/principal.py +69 -0
- agentgov/provenance.py +135 -0
- agentgov/py.typed +0 -0
- agentgov/revocation.py +95 -0
- agentgov/says.py +85 -0
- hapax_agentgov-0.2.0.dist-info/METADATA +166 -0
- hapax_agentgov-0.2.0.dist-info/RECORD +18 -0
- hapax_agentgov-0.2.0.dist-info/WHEEL +4 -0
- hapax_agentgov-0.2.0.dist-info/licenses/LICENSE +21 -0
agentgov/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""agentgov — Computational constitutional governance for AI agent systems.
|
|
2
|
+
|
|
3
|
+
Pure governance logic with algebraic guarantees:
|
|
4
|
+
- ConsentLabel: join-semilattice (associative, commutative, idempotent)
|
|
5
|
+
- Labeled[T]: functor (identity, composition)
|
|
6
|
+
- Principal: non-amplification (bound <= delegator authority)
|
|
7
|
+
- Governor: consistent with can_flow_to
|
|
8
|
+
- ProvenanceExpr: PosBool(X) semiring
|
|
9
|
+
- VetoChain: deny-wins composition
|
|
10
|
+
- Says: DCC-style principal attribution monad
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from agentgov.agent_governor import create_agent_governor
|
|
14
|
+
from agentgov.carrier import CarrierFact, CarrierRegistry, DisplacementResult
|
|
15
|
+
from agentgov.consent import ConsentContract, ConsentRegistry, load_contracts
|
|
16
|
+
from agentgov.consent_label import ConsentLabel
|
|
17
|
+
from agentgov.governor import (
|
|
18
|
+
GovernorDenial,
|
|
19
|
+
GovernorPolicy,
|
|
20
|
+
GovernorResult,
|
|
21
|
+
GovernorWrapper,
|
|
22
|
+
consent_input_policy,
|
|
23
|
+
consent_output_policy,
|
|
24
|
+
)
|
|
25
|
+
from agentgov.hooks import (
|
|
26
|
+
HookResult,
|
|
27
|
+
scan_attribution_entities,
|
|
28
|
+
scan_management_boundary,
|
|
29
|
+
scan_pii,
|
|
30
|
+
scan_provenance_references,
|
|
31
|
+
scan_single_user_violations,
|
|
32
|
+
validate_all,
|
|
33
|
+
)
|
|
34
|
+
from agentgov.labeled import Labeled
|
|
35
|
+
from agentgov.primitives import (
|
|
36
|
+
Candidate,
|
|
37
|
+
FallbackChain,
|
|
38
|
+
GatedResult,
|
|
39
|
+
Selected,
|
|
40
|
+
Veto,
|
|
41
|
+
VetoChain,
|
|
42
|
+
VetoResult,
|
|
43
|
+
)
|
|
44
|
+
from agentgov.principal import Principal, PrincipalKind
|
|
45
|
+
from agentgov.provenance import ProvenanceExpr
|
|
46
|
+
from agentgov.revocation import (
|
|
47
|
+
PurgeResult,
|
|
48
|
+
RevocationPropagator,
|
|
49
|
+
RevocationReport,
|
|
50
|
+
check_provenance,
|
|
51
|
+
)
|
|
52
|
+
from agentgov.says import Says
|
|
53
|
+
|
|
54
|
+
__version__ = "0.2.0"
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
# Principal model
|
|
58
|
+
"Principal",
|
|
59
|
+
"PrincipalKind",
|
|
60
|
+
# Consent
|
|
61
|
+
"ConsentContract",
|
|
62
|
+
"ConsentRegistry",
|
|
63
|
+
"ConsentLabel",
|
|
64
|
+
"load_contracts",
|
|
65
|
+
# Labeled data
|
|
66
|
+
"Labeled",
|
|
67
|
+
# Provenance
|
|
68
|
+
"ProvenanceExpr",
|
|
69
|
+
# Carrier dynamics
|
|
70
|
+
"CarrierFact",
|
|
71
|
+
"CarrierRegistry",
|
|
72
|
+
"DisplacementResult",
|
|
73
|
+
# Governor
|
|
74
|
+
"GovernorWrapper",
|
|
75
|
+
"GovernorPolicy",
|
|
76
|
+
"GovernorResult",
|
|
77
|
+
"GovernorDenial",
|
|
78
|
+
"consent_input_policy",
|
|
79
|
+
"consent_output_policy",
|
|
80
|
+
"create_agent_governor",
|
|
81
|
+
# Revocation cascade
|
|
82
|
+
"RevocationPropagator",
|
|
83
|
+
"RevocationReport",
|
|
84
|
+
"PurgeResult",
|
|
85
|
+
"check_provenance",
|
|
86
|
+
# Compositional primitives
|
|
87
|
+
"Candidate",
|
|
88
|
+
"FallbackChain",
|
|
89
|
+
"GatedResult",
|
|
90
|
+
"Selected",
|
|
91
|
+
"Veto",
|
|
92
|
+
"VetoChain",
|
|
93
|
+
"VetoResult",
|
|
94
|
+
# Says monad
|
|
95
|
+
"Says",
|
|
96
|
+
# Governance hooks
|
|
97
|
+
"HookResult",
|
|
98
|
+
"scan_pii",
|
|
99
|
+
"scan_single_user_violations",
|
|
100
|
+
"scan_attribution_entities",
|
|
101
|
+
"scan_provenance_references",
|
|
102
|
+
"scan_management_boundary",
|
|
103
|
+
"validate_all",
|
|
104
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Agent governor factory: builds GovernorWrapper from axiom bindings.
|
|
2
|
+
|
|
3
|
+
Translates declarative axiom bindings in agent manifests into runtime
|
|
4
|
+
governance policies. Each agent gets a GovernorWrapper configured with
|
|
5
|
+
input/output policies derived from its axiom relationships.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentgov.consent_label import ConsentLabel
|
|
15
|
+
from agentgov.governor import (
|
|
16
|
+
GovernorPolicy,
|
|
17
|
+
GovernorWrapper,
|
|
18
|
+
consent_input_policy,
|
|
19
|
+
consent_output_policy,
|
|
20
|
+
)
|
|
21
|
+
from agentgov.labeled import Labeled
|
|
22
|
+
|
|
23
|
+
_log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
PolicyBuilder = Callable[[str], tuple[list[GovernorPolicy], list[GovernorPolicy]]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def interpersonal_transparency_policies(
|
|
29
|
+
role: str,
|
|
30
|
+
) -> tuple[list[GovernorPolicy], list[GovernorPolicy]]:
|
|
31
|
+
"""Build policies for the interpersonal_transparency axiom."""
|
|
32
|
+
input_policies: list[GovernorPolicy] = []
|
|
33
|
+
output_policies: list[GovernorPolicy] = []
|
|
34
|
+
|
|
35
|
+
if role in ("subject", "enforcer"):
|
|
36
|
+
input_policies.append(consent_input_policy(ConsentLabel.bottom()))
|
|
37
|
+
output_policies.append(consent_output_policy(ConsentLabel.bottom()))
|
|
38
|
+
|
|
39
|
+
return input_policies, output_policies
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def corporate_boundary_policies(
|
|
43
|
+
role: str,
|
|
44
|
+
) -> tuple[list[GovernorPolicy], list[GovernorPolicy]]:
|
|
45
|
+
"""Build policies for the corporate_boundary axiom."""
|
|
46
|
+
input_policies: list[GovernorPolicy] = []
|
|
47
|
+
output_policies: list[GovernorPolicy] = []
|
|
48
|
+
|
|
49
|
+
if role in ("subject", "enforcer"):
|
|
50
|
+
|
|
51
|
+
def _no_work_data(_agent_id: str, data: Labeled[Any]) -> bool:
|
|
52
|
+
if not (hasattr(data, "metadata") and isinstance(data.metadata, dict)):
|
|
53
|
+
_log.warning("corporate_boundary: denying data with no metadata dict (fail-closed)")
|
|
54
|
+
return False
|
|
55
|
+
category = data.metadata.get("data_category")
|
|
56
|
+
if category is None:
|
|
57
|
+
_log.warning(
|
|
58
|
+
"corporate_boundary: denying data with no data_category key (fail-closed)"
|
|
59
|
+
)
|
|
60
|
+
return False
|
|
61
|
+
return category != "work"
|
|
62
|
+
|
|
63
|
+
output_policies.append(
|
|
64
|
+
GovernorPolicy(
|
|
65
|
+
name="corporate_boundary_output",
|
|
66
|
+
check=_no_work_data,
|
|
67
|
+
axiom_id="corporate_boundary",
|
|
68
|
+
description="Block work data from persisting to home system",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return input_policies, output_policies
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
DEFAULT_AXIOM_BUILDERS: dict[str, PolicyBuilder] = {
|
|
76
|
+
"interpersonal_transparency": interpersonal_transparency_policies,
|
|
77
|
+
"corporate_boundary": corporate_boundary_policies,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_agent_governor(
|
|
82
|
+
agent_id: str,
|
|
83
|
+
axiom_bindings: list[dict[str, Any]] | None = None,
|
|
84
|
+
*,
|
|
85
|
+
axiom_builders: dict[str, PolicyBuilder] | None = None,
|
|
86
|
+
binding_loader: Callable[[str], list[Any]] | None = None,
|
|
87
|
+
) -> GovernorWrapper:
|
|
88
|
+
"""Build a GovernorWrapper from agent manifest axiom bindings.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
agent_id: Agent identifier.
|
|
92
|
+
axiom_bindings: List of binding dicts with keys 'axiom_id', 'role'.
|
|
93
|
+
axiom_builders: Custom axiom-to-policy mapping. Defaults to
|
|
94
|
+
built-in interpersonal_transparency and corporate_boundary.
|
|
95
|
+
binding_loader: Optional callable to load bindings from an
|
|
96
|
+
external registry when axiom_bindings is None.
|
|
97
|
+
"""
|
|
98
|
+
gov = GovernorWrapper(agent_id)
|
|
99
|
+
builders = axiom_builders or DEFAULT_AXIOM_BUILDERS
|
|
100
|
+
|
|
101
|
+
bindings = axiom_bindings
|
|
102
|
+
if bindings is None and binding_loader is not None:
|
|
103
|
+
bindings = binding_loader(agent_id)
|
|
104
|
+
if bindings is None:
|
|
105
|
+
bindings = []
|
|
106
|
+
|
|
107
|
+
for binding in bindings:
|
|
108
|
+
axiom_id = binding.get("axiom_id", "") if isinstance(binding, dict) else binding.axiom_id
|
|
109
|
+
role = binding.get("role", "subject") if isinstance(binding, dict) else binding.role
|
|
110
|
+
|
|
111
|
+
builder = builders.get(axiom_id)
|
|
112
|
+
if builder is None:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
input_policies, output_policies = builder(role)
|
|
116
|
+
for p in input_policies:
|
|
117
|
+
gov.add_input_policy(p)
|
|
118
|
+
for p in output_policies:
|
|
119
|
+
gov.add_output_policy(p)
|
|
120
|
+
|
|
121
|
+
_log.debug(
|
|
122
|
+
"Governor %s: added %d input + %d output policies for %s",
|
|
123
|
+
agent_id,
|
|
124
|
+
len(input_policies),
|
|
125
|
+
len(output_policies),
|
|
126
|
+
axiom_id,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return gov
|
agentgov/carrier.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Epistemic carrier dynamics: bounded cross-domain fact carrying.
|
|
2
|
+
|
|
3
|
+
Each agent carries a small set of foreign-domain facts observed
|
|
4
|
+
incidentally through contact topology. Carrier facts are Labeled[Any]
|
|
5
|
+
values — consent labels travel with carried facts via the DLM join.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentgov.consent_label import ConsentLabel
|
|
15
|
+
from agentgov.labeled import Labeled
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CarrierFact:
|
|
20
|
+
"""A foreign-domain fact carried incidentally by an agent."""
|
|
21
|
+
|
|
22
|
+
labeled: Labeled[Any]
|
|
23
|
+
source_domain: str
|
|
24
|
+
observation_count: int = 1
|
|
25
|
+
first_seen: float = 0.0
|
|
26
|
+
last_seen: float = 0.0
|
|
27
|
+
|
|
28
|
+
def observe(self, timestamp: float) -> CarrierFact:
|
|
29
|
+
"""Return a new instance with incremented count and updated last_seen."""
|
|
30
|
+
return CarrierFact(
|
|
31
|
+
labeled=self.labeled,
|
|
32
|
+
source_domain=self.source_domain,
|
|
33
|
+
observation_count=self.observation_count + 1,
|
|
34
|
+
first_seen=self.first_seen,
|
|
35
|
+
last_seen=timestamp,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def same_fact(self, other: CarrierFact) -> bool:
|
|
39
|
+
"""Check if two carrier facts represent the same observation."""
|
|
40
|
+
return (
|
|
41
|
+
self.labeled.value == other.labeled.value and self.source_domain == other.source_domain
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def consent_label(self) -> ConsentLabel:
|
|
46
|
+
return self.labeled.label
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def provenance(self) -> frozenset[str]:
|
|
50
|
+
return self.labeled.provenance
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class DisplacementResult:
|
|
55
|
+
"""Outcome of offering a carrier fact to a registry."""
|
|
56
|
+
|
|
57
|
+
inserted: bool
|
|
58
|
+
displaced: CarrierFact | None = None
|
|
59
|
+
reason: str = ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CarrierRegistry:
|
|
63
|
+
"""Mutable registry of carrier facts per principal.
|
|
64
|
+
|
|
65
|
+
Enforces bounded capacity per principal. Displacement follows
|
|
66
|
+
frequency-weighted policy: new facts must be observed significantly
|
|
67
|
+
more frequently than the least-observed existing fact to displace it.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
__slots__ = ("_slots", "_capacities", "displacement_threshold")
|
|
71
|
+
|
|
72
|
+
def __init__(self, displacement_threshold: float = 2.0) -> None:
|
|
73
|
+
self._slots: dict[str, list[CarrierFact]] = {}
|
|
74
|
+
self._capacities: dict[str, int] = {}
|
|
75
|
+
self.displacement_threshold = displacement_threshold
|
|
76
|
+
|
|
77
|
+
def register(self, principal_id: str, capacity: int) -> None:
|
|
78
|
+
if capacity < 0:
|
|
79
|
+
raise ValueError(f"Carrier capacity must be non-negative, got {capacity}")
|
|
80
|
+
self._slots.setdefault(principal_id, [])
|
|
81
|
+
self._capacities[principal_id] = capacity
|
|
82
|
+
|
|
83
|
+
def facts(self, principal_id: str) -> tuple[CarrierFact, ...]:
|
|
84
|
+
return tuple(self._slots.get(principal_id, []))
|
|
85
|
+
|
|
86
|
+
def offer(self, principal_id: str, fact: CarrierFact) -> DisplacementResult:
|
|
87
|
+
"""Offer a carrier fact to a principal's slots."""
|
|
88
|
+
if principal_id not in self._capacities:
|
|
89
|
+
raise ValueError(f"Principal {principal_id} not registered")
|
|
90
|
+
|
|
91
|
+
slots = self._slots[principal_id]
|
|
92
|
+
capacity = self._capacities[principal_id]
|
|
93
|
+
|
|
94
|
+
for i, existing in enumerate(slots):
|
|
95
|
+
if existing.same_fact(fact):
|
|
96
|
+
slots[i] = existing.observe(fact.last_seen)
|
|
97
|
+
return DisplacementResult(inserted=True, reason="updated existing")
|
|
98
|
+
|
|
99
|
+
if len(slots) < capacity:
|
|
100
|
+
slots.append(fact)
|
|
101
|
+
return DisplacementResult(inserted=True, reason="slot available")
|
|
102
|
+
|
|
103
|
+
if not slots:
|
|
104
|
+
return DisplacementResult(inserted=False, reason="zero capacity")
|
|
105
|
+
|
|
106
|
+
least_idx = min(range(len(slots)), key=lambda i: slots[i].observation_count)
|
|
107
|
+
least = slots[least_idx]
|
|
108
|
+
|
|
109
|
+
if fact.observation_count > least.observation_count * self.displacement_threshold:
|
|
110
|
+
displaced = slots[least_idx]
|
|
111
|
+
slots[least_idx] = fact
|
|
112
|
+
return DisplacementResult(inserted=True, displaced=displaced, reason="displaced")
|
|
113
|
+
|
|
114
|
+
return DisplacementResult(
|
|
115
|
+
inserted=False,
|
|
116
|
+
reason=f"insufficient frequency: {fact.observation_count} <= "
|
|
117
|
+
f"{least.observation_count} * {self.displacement_threshold}",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def purge_by_provenance(self, contract_id: str) -> int:
|
|
121
|
+
"""Remove carrier facts whose provenance includes contract_id."""
|
|
122
|
+
purged = 0
|
|
123
|
+
for slots in self._slots.values():
|
|
124
|
+
before = len(slots)
|
|
125
|
+
slots[:] = [f for f in slots if contract_id not in f.provenance]
|
|
126
|
+
purged += before - len(slots)
|
|
127
|
+
return purged
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def epistemic_contradiction_veto(
|
|
131
|
+
local_knowledge: Callable[[str, Any], bool],
|
|
132
|
+
) -> Callable[[CarrierFact], bool]:
|
|
133
|
+
"""Create a predicate that checks carrier facts against local knowledge."""
|
|
134
|
+
|
|
135
|
+
def _check(fact: CarrierFact) -> bool:
|
|
136
|
+
return local_knowledge(fact.source_domain, fact.labeled.value)
|
|
137
|
+
|
|
138
|
+
return _check
|
agentgov/consent.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Consent contract management for information flow governance.
|
|
2
|
+
|
|
3
|
+
Provides contract loading, validation, and enforcement at data ingestion
|
|
4
|
+
boundaries. Any data pathway handling person data must call
|
|
5
|
+
contract_check() before persisting state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConsentContractLoadError(Exception):
|
|
23
|
+
"""Raised when a contract YAML file fails to parse in strict mode."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ConsentContract:
|
|
28
|
+
"""A bilateral consent agreement between operator and subject.
|
|
29
|
+
|
|
30
|
+
Immutable once loaded. Revocation creates a new record, it does not
|
|
31
|
+
mutate the existing contract.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
id: str
|
|
35
|
+
parties: tuple[str, str]
|
|
36
|
+
scope: frozenset[str]
|
|
37
|
+
direction: str = "one_way"
|
|
38
|
+
visibility_mechanism: str = "on_request"
|
|
39
|
+
created_at: str = ""
|
|
40
|
+
revoked_at: str | None = None
|
|
41
|
+
principal_class: str = ""
|
|
42
|
+
guardian: str | None = None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def active(self) -> bool:
|
|
46
|
+
return self.revoked_at is None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ConsentRegistry:
|
|
51
|
+
"""Runtime registry of consent contracts.
|
|
52
|
+
|
|
53
|
+
Loaded from YAML files on disk. Provides contract_check() for
|
|
54
|
+
ingestion boundary enforcement.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_contracts: dict[str, ConsentContract] = field(default_factory=dict)
|
|
58
|
+
_fail_closed: bool = field(default=False)
|
|
59
|
+
_loaded_at: float = field(default=0.0)
|
|
60
|
+
_contracts_dir: Path | None = field(default=None)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def fail_closed(self) -> bool:
|
|
64
|
+
return self._fail_closed
|
|
65
|
+
|
|
66
|
+
def is_stale(self, stale_threshold_s: float = 300.0) -> bool:
|
|
67
|
+
"""Check if the registry was loaded too long ago to trust."""
|
|
68
|
+
if self._loaded_at == 0.0:
|
|
69
|
+
return False
|
|
70
|
+
return time.time() - self._loaded_at > stale_threshold_s
|
|
71
|
+
|
|
72
|
+
def load(self, contracts_dir: Path | None = None, *, strict: bool = False) -> int:
|
|
73
|
+
"""Load all contract files from the contracts directory.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
contracts_dir: Path to scan for YAML contract files.
|
|
77
|
+
strict: When True, raise ConsentContractLoadError on any
|
|
78
|
+
malformed YAML instead of logging and skipping.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The number of active contracts loaded.
|
|
82
|
+
"""
|
|
83
|
+
directory = contracts_dir or self._contracts_dir
|
|
84
|
+
if directory is None:
|
|
85
|
+
log.info("No contracts directory configured")
|
|
86
|
+
self._fail_closed = True
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
if not directory.exists():
|
|
91
|
+
log.info("No contracts directory at %s", directory)
|
|
92
|
+
self._fail_closed = True
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
count = 0
|
|
96
|
+
for path in sorted(directory.glob("*.yaml")):
|
|
97
|
+
try:
|
|
98
|
+
data = yaml.safe_load(path.read_text())
|
|
99
|
+
if data is None:
|
|
100
|
+
continue
|
|
101
|
+
contract = parse_contract(data)
|
|
102
|
+
self._contracts[contract.id] = contract
|
|
103
|
+
if contract.active:
|
|
104
|
+
count += 1
|
|
105
|
+
log.info(
|
|
106
|
+
"Loaded contract %s: %s <-> %s (scope: %s)",
|
|
107
|
+
contract.id,
|
|
108
|
+
contract.parties[0],
|
|
109
|
+
contract.parties[1],
|
|
110
|
+
", ".join(sorted(contract.scope)),
|
|
111
|
+
)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
if strict:
|
|
114
|
+
raise ConsentContractLoadError(
|
|
115
|
+
f"Failed to load contract from {path}: {exc}"
|
|
116
|
+
) from exc
|
|
117
|
+
log.exception("Failed to load contract from %s", path)
|
|
118
|
+
|
|
119
|
+
self._fail_closed = False
|
|
120
|
+
self._loaded_at = time.time()
|
|
121
|
+
return count
|
|
122
|
+
except ConsentContractLoadError:
|
|
123
|
+
raise
|
|
124
|
+
except Exception:
|
|
125
|
+
log.exception("Failed to load contracts from %s", directory)
|
|
126
|
+
self._fail_closed = True
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
def get(self, contract_id: str) -> ConsentContract | None:
|
|
130
|
+
return self._contracts.get(contract_id)
|
|
131
|
+
|
|
132
|
+
def __iter__(self):
|
|
133
|
+
return iter(self._contracts.values())
|
|
134
|
+
|
|
135
|
+
def contract_check(self, person_id: str, data_category: str) -> bool:
|
|
136
|
+
"""Check whether an active contract permits this data flow.
|
|
137
|
+
|
|
138
|
+
Returns True if an active contract exists for the given person
|
|
139
|
+
with the given data category in scope. Returns False otherwise.
|
|
140
|
+
"""
|
|
141
|
+
if self._fail_closed or self.is_stale():
|
|
142
|
+
return False
|
|
143
|
+
for contract in self._contracts.values():
|
|
144
|
+
if not contract.active:
|
|
145
|
+
continue
|
|
146
|
+
if person_id in contract.parties and data_category in contract.scope:
|
|
147
|
+
return True
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
def get_contract_for(self, person_id: str) -> ConsentContract | None:
|
|
151
|
+
"""Return the active contract for a person, if any."""
|
|
152
|
+
for contract in self._contracts.values():
|
|
153
|
+
if contract.active and person_id in contract.parties:
|
|
154
|
+
return contract
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def subject_data_categories(self, person_id: str) -> frozenset[str]:
|
|
158
|
+
"""Return all permitted data categories for a person."""
|
|
159
|
+
categories: set[str] = set()
|
|
160
|
+
for contract in self._contracts.values():
|
|
161
|
+
if contract.active and person_id in contract.parties:
|
|
162
|
+
categories |= contract.scope
|
|
163
|
+
return frozenset(categories)
|
|
164
|
+
|
|
165
|
+
def revoke_contract(
|
|
166
|
+
self,
|
|
167
|
+
contract_id: str,
|
|
168
|
+
*,
|
|
169
|
+
contracts_dir: Path | None = None,
|
|
170
|
+
) -> float:
|
|
171
|
+
"""Revoke a single consent contract by ID.
|
|
172
|
+
|
|
173
|
+
Returns the wall-clock seconds the revocation took.
|
|
174
|
+
Raises KeyError if the contract_id is not registered.
|
|
175
|
+
"""
|
|
176
|
+
t0 = time.monotonic()
|
|
177
|
+
contract = self._contracts.get(contract_id)
|
|
178
|
+
if contract is None:
|
|
179
|
+
raise KeyError(f"Contract {contract_id} not registered")
|
|
180
|
+
|
|
181
|
+
now_iso = datetime.now().isoformat()
|
|
182
|
+
revoked_contract = ConsentContract(
|
|
183
|
+
id=contract.id,
|
|
184
|
+
parties=contract.parties,
|
|
185
|
+
scope=contract.scope,
|
|
186
|
+
direction=contract.direction,
|
|
187
|
+
visibility_mechanism=contract.visibility_mechanism,
|
|
188
|
+
created_at=contract.created_at,
|
|
189
|
+
revoked_at=now_iso,
|
|
190
|
+
principal_class=contract.principal_class,
|
|
191
|
+
guardian=contract.guardian,
|
|
192
|
+
)
|
|
193
|
+
self._contracts[contract_id] = revoked_contract
|
|
194
|
+
|
|
195
|
+
directory = contracts_dir or self._contracts_dir
|
|
196
|
+
if directory is not None:
|
|
197
|
+
src = directory / f"{contract_id}.yaml"
|
|
198
|
+
if src.exists():
|
|
199
|
+
revoked_dir = directory / "revoked"
|
|
200
|
+
revoked_dir.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
stamp = now_iso[:10]
|
|
202
|
+
dst = revoked_dir / f"{stamp}-{contract_id}.yaml"
|
|
203
|
+
n = 2
|
|
204
|
+
while dst.exists():
|
|
205
|
+
dst = revoked_dir / f"{stamp}-{contract_id}-{n}.yaml"
|
|
206
|
+
n += 1
|
|
207
|
+
src.rename(dst)
|
|
208
|
+
log.info("Revoked contract %s — moved YAML to %s", contract_id, dst)
|
|
209
|
+
|
|
210
|
+
elapsed = time.monotonic() - t0
|
|
211
|
+
return elapsed
|
|
212
|
+
|
|
213
|
+
def purge_subject(self, person_id: str) -> list[str]:
|
|
214
|
+
"""Mark all contracts for a person as revoked. Returns revoked IDs."""
|
|
215
|
+
revoked: list[str] = []
|
|
216
|
+
for contract_id, contract in self._contracts.items():
|
|
217
|
+
if contract.active and person_id in contract.parties:
|
|
218
|
+
revoked_contract = ConsentContract(
|
|
219
|
+
id=contract.id,
|
|
220
|
+
parties=contract.parties,
|
|
221
|
+
scope=contract.scope,
|
|
222
|
+
direction=contract.direction,
|
|
223
|
+
visibility_mechanism=contract.visibility_mechanism,
|
|
224
|
+
created_at=contract.created_at,
|
|
225
|
+
revoked_at=datetime.now().isoformat(),
|
|
226
|
+
principal_class=contract.principal_class,
|
|
227
|
+
guardian=contract.guardian,
|
|
228
|
+
)
|
|
229
|
+
self._contracts[contract_id] = revoked_contract
|
|
230
|
+
revoked.append(contract_id)
|
|
231
|
+
log.info("Revoked contract %s for %s", contract_id, person_id)
|
|
232
|
+
return revoked
|
|
233
|
+
|
|
234
|
+
def create_contract(
|
|
235
|
+
self,
|
|
236
|
+
person_id: str,
|
|
237
|
+
scope: frozenset[str],
|
|
238
|
+
*,
|
|
239
|
+
contract_id: str | None = None,
|
|
240
|
+
direction: str = "one_way",
|
|
241
|
+
visibility_mechanism: str = "on_request",
|
|
242
|
+
contracts_dir: Path | None = None,
|
|
243
|
+
) -> ConsentContract:
|
|
244
|
+
"""Create and activate a new consent contract at runtime."""
|
|
245
|
+
now = datetime.now().isoformat()
|
|
246
|
+
cid = contract_id or f"contract-{person_id}-{now[:10]}"
|
|
247
|
+
|
|
248
|
+
contract = ConsentContract(
|
|
249
|
+
id=cid,
|
|
250
|
+
parties=("operator", person_id),
|
|
251
|
+
scope=scope,
|
|
252
|
+
direction=direction,
|
|
253
|
+
visibility_mechanism=visibility_mechanism,
|
|
254
|
+
created_at=now,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
directory = contracts_dir or self._contracts_dir
|
|
258
|
+
if directory is not None:
|
|
259
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
contract_path = directory / f"{cid}.yaml"
|
|
261
|
+
contract_data: dict[str, Any] = {
|
|
262
|
+
"id": contract.id,
|
|
263
|
+
"parties": list(contract.parties),
|
|
264
|
+
"scope": sorted(contract.scope),
|
|
265
|
+
"direction": contract.direction,
|
|
266
|
+
"visibility_mechanism": contract.visibility_mechanism,
|
|
267
|
+
"created_at": contract.created_at,
|
|
268
|
+
}
|
|
269
|
+
if contract.principal_class:
|
|
270
|
+
contract_data["principal_class"] = contract.principal_class
|
|
271
|
+
if contract.guardian:
|
|
272
|
+
contract_data["guardian"] = contract.guardian
|
|
273
|
+
contract_path.write_text(yaml.dump(contract_data, default_flow_style=False))
|
|
274
|
+
log.info("Created consent contract %s for %s at %s", cid, person_id, contract_path)
|
|
275
|
+
|
|
276
|
+
self._contracts[cid] = contract
|
|
277
|
+
return contract
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def active_contracts(self) -> list[ConsentContract]:
|
|
281
|
+
return [c for c in self._contracts.values() if c.active]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def parse_contract(data: dict[str, Any]) -> ConsentContract:
|
|
285
|
+
"""Parse a contract YAML dict into a ConsentContract."""
|
|
286
|
+
parties = data.get("parties", [])
|
|
287
|
+
if len(parties) != 2:
|
|
288
|
+
raise ValueError(f"Contract must have exactly 2 parties, got {len(parties)}")
|
|
289
|
+
|
|
290
|
+
return ConsentContract(
|
|
291
|
+
id=data["id"],
|
|
292
|
+
parties=(parties[0], parties[1]),
|
|
293
|
+
scope=frozenset(data.get("scope", [])),
|
|
294
|
+
direction=data.get("direction", "one_way"),
|
|
295
|
+
visibility_mechanism=data.get("visibility_mechanism", "on_request"),
|
|
296
|
+
created_at=data.get("created_at", ""),
|
|
297
|
+
revoked_at=data.get("revoked_at"),
|
|
298
|
+
principal_class=data.get("principal_class", ""),
|
|
299
|
+
guardian=data.get("guardian"),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def load_contracts(contracts_dir: Path | None = None) -> ConsentRegistry:
|
|
304
|
+
"""Convenience function: create and load a ConsentRegistry."""
|
|
305
|
+
registry = ConsentRegistry(_contracts_dir=contracts_dir)
|
|
306
|
+
registry.load(contracts_dir)
|
|
307
|
+
return registry
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def check_consent_state_freshness(path: Path, *, stale_threshold_s: float = 300.0) -> bool:
|
|
311
|
+
"""Check if a consent state file on disk is fresh enough to trust."""
|
|
312
|
+
try:
|
|
313
|
+
mtime = path.stat().st_mtime
|
|
314
|
+
return (time.time() - mtime) < stale_threshold_s
|
|
315
|
+
except OSError:
|
|
316
|
+
return False
|