Seeded repo with intent and initial content.
This commit is contained in:
parent
86b9063721
commit
12d0c31cff
27 changed files with 2683 additions and 1 deletions
16
src/emergent_security/__init__.py
Normal file
16
src/emergent_security/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""Reference implementation of the Emergent Security decision function (canon v0.8).
|
||||
|
||||
Modules, one concern each:
|
||||
constitution rule identifiers
|
||||
model Request, Decision, Outcome (pure data)
|
||||
scales ordinal risk scales and comparisons
|
||||
envelope the default envelope (canon A6)
|
||||
delegation registry delegations and the Grant they produce
|
||||
memory security memory entries
|
||||
tenant platform tenant envelopes, tenant governance, validation
|
||||
decide the ordered decision function (canon A3)
|
||||
"""
|
||||
|
||||
from emergent_security.decide import decide
|
||||
|
||||
__all__ = ["decide"]
|
||||
13
src/emergent_security/constitution.py
Normal file
13
src/emergent_security/constitution.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Stable rule identifiers of the canon (constitution C*, precedence P*)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
CONSTITUTION = frozenset(f"C{number}" for number in range(1, 15))
|
||||
PRECEDENCE = frozenset(f"P{number}" for number in range(1, 8))
|
||||
RULE_IDS = CONSTITUTION | PRECEDENCE
|
||||
|
||||
# Rule cited when a platform tenant envelope bounds an effect, and when a tenant's own
|
||||
# governance envelope prohibits it.
|
||||
PLATFORM_TENANT_BOUND = "C14"
|
||||
TENANT_GOVERNANCE = "P6"
|
||||
ROOT = "C12"
|
||||
121
src/emergent_security/decide.py
Normal file
121
src/emergent_security/decide.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""The decision function (canon A3).
|
||||
|
||||
`decide` is pure: it reads its inputs, never mutates them, holds no state and calls no
|
||||
model. The same request, envelope, registry, tenant context and memory always produce
|
||||
the same Decision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from emergent_security.constitution import PLATFORM_TENANT_BOUND, ROOT
|
||||
from emergent_security.delegation import Delegation, Grant, select_delegation
|
||||
from emergent_security.envelope import DefaultEnvelope, Row
|
||||
from emergent_security.memory import MemoryEntry
|
||||
from emergent_security.model import Decision, Outcome, Request
|
||||
from emergent_security.scales import RiskScales
|
||||
from emergent_security.tenant import TenantContext
|
||||
|
||||
REQUIRED_SCOPE = ("level", "temperature", "phase", "tenant")
|
||||
|
||||
|
||||
def decide(
|
||||
request: Request,
|
||||
*,
|
||||
envelope: DefaultEnvelope,
|
||||
scales: RiskScales,
|
||||
registry: Iterable[Delegation] = (),
|
||||
tenant: TenantContext | None = None,
|
||||
memory: Iterable[MemoryEntry] = (),
|
||||
) -> Decision:
|
||||
"""Evaluate one privileged effect; the first matching step decides."""
|
||||
|
||||
def escalate(condition: str, route_to: str, **fields) -> Decision:
|
||||
return Decision(request.request_id, request.decision_class, Outcome.ESCALATE,
|
||||
blocking_condition=condition, route_to=route_to, **fields)
|
||||
|
||||
# 1. scope unresolved
|
||||
unresolved = _unresolved_scope(request)
|
||||
if unresolved:
|
||||
return escalate("need_context", "delegator", missing=unresolved)
|
||||
|
||||
row = envelope.row(request.decision_class, request.attributes)
|
||||
|
||||
# 2. constitution or explicit prohibition
|
||||
if row is not None and row.mode is Outcome.DENY:
|
||||
return _deny(request, row.rules, envelope.id)
|
||||
if tenant is not None and tenant.prohibition(request.decision_class):
|
||||
return _deny(request, tenant.prohibition(request.decision_class), tenant.platform.id)
|
||||
|
||||
# 3. platform-root
|
||||
if request.scope.get("level") == "L4" or (row is not None and row.mode is Outcome.ROOT_AUTHORIZATION_REQUIRED):
|
||||
return Decision(request.request_id, request.decision_class, Outcome.ROOT_AUTHORIZATION_REQUIRED,
|
||||
route_to="root_steward", rules=(ROOT,), source=envelope.id)
|
||||
|
||||
# 4. outside the platform tenant envelope
|
||||
if tenant is not None:
|
||||
outside = tenant.outside(request, scales)
|
||||
if outside:
|
||||
return escalate("outside_tenant_envelope", "platform", rules=(PLATFORM_TENANT_BOUND,), missing=outside)
|
||||
|
||||
route = "platform" if tenant is not None else "delegator"
|
||||
|
||||
# 5. delegation within the effective envelope
|
||||
grant = _grant(request, row, envelope, registry, tenant, scales)
|
||||
if grant is None:
|
||||
return escalate("no_delegation", route, rules=row.rules if row else ())
|
||||
|
||||
# 6. negative memory
|
||||
if any(entry.blocks(request) for entry in memory):
|
||||
return escalate("negative_memory", route, source=grant.source)
|
||||
|
||||
# 7. evidence
|
||||
missing_evidence = tuple(item for item in grant.evidence_required if item not in request.evidence)
|
||||
if missing_evidence:
|
||||
return escalate("missing_evidence", route, missing=missing_evidence, source=grant.source)
|
||||
|
||||
# 8. risk against the envelope's declared dimensions
|
||||
exceeded = scales.exceeded(request.risk, grant.risk_envelope)
|
||||
if exceeded:
|
||||
return escalate("risk", route, missing=exceeded, source=grant.source)
|
||||
|
||||
# 9. tenant reserved the class for a human decision
|
||||
if tenant is not None and tenant.requires_human(request.decision_class):
|
||||
return escalate("tenant_human_decision", "tenant_admin", source=grant.source)
|
||||
|
||||
# 10. autonomous
|
||||
return Decision(request.request_id, request.decision_class, grant.outcome,
|
||||
rules=grant.rules, obligations=grant.obligations, source=grant.source)
|
||||
|
||||
|
||||
def _unresolved_scope(request: Request) -> tuple[str, ...]:
|
||||
missing = [name for name in ("principal", "decision_class") if not getattr(request, name)]
|
||||
missing += [f"scope.{key}" for key in REQUIRED_SCOPE if request.scope.get(key) in (None, "")]
|
||||
return tuple(missing)
|
||||
|
||||
|
||||
def _deny(request: Request, rules: tuple[str, ...], source: str) -> Decision:
|
||||
return Decision(request.request_id, request.decision_class, Outcome.DENY, rules=rules, source=source)
|
||||
|
||||
|
||||
def _grant(
|
||||
request: Request,
|
||||
row: Row | None,
|
||||
envelope: DefaultEnvelope,
|
||||
registry: Iterable[Delegation],
|
||||
tenant: TenantContext | None,
|
||||
scales: RiskScales,
|
||||
) -> Grant | None:
|
||||
"""Registry first, then the platform tenant envelope, then the default envelope (A10)."""
|
||||
delegation = select_delegation(registry, request)
|
||||
if delegation is not None:
|
||||
grant = delegation.as_grant()
|
||||
elif tenant is not None:
|
||||
grant = tenant.platform.grant_for(request.decision_class)
|
||||
elif row is not None and row.mode is not None and row.mode.grants and envelope.applies_to(request.scope):
|
||||
grant = Grant(source=envelope.id, outcome=row.mode, obligations=row.obligations,
|
||||
evidence_required=row.evidence_required, risk_envelope=envelope.risk_envelope, rules=row.rules)
|
||||
else:
|
||||
return None
|
||||
return grant.bounded_by(tenant.ceilings(scales), scales) if tenant is not None and grant else grant
|
||||
75
src/emergent_security/delegation.py
Normal file
75
src/emergent_security/delegation.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Registry delegations (canon B1) and the Grant every delegation source resolves to."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from emergent_security.model import Outcome, Request, frozen_mapping, frozen_tuple
|
||||
from emergent_security.scales import RiskScales
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Grant:
|
||||
"""What a delegation source allows for one request; source-agnostic input to A3 steps 6-10."""
|
||||
|
||||
source: str
|
||||
outcome: Outcome
|
||||
obligations: tuple[str, ...] = ()
|
||||
evidence_required: tuple[str, ...] = ()
|
||||
risk_envelope: Mapping[str, Any] = frozen_mapping(None)
|
||||
rules: tuple[str, ...] = ()
|
||||
|
||||
def bounded_by(self, ceilings: Mapping[str, Any], scales: RiskScales) -> Grant:
|
||||
return replace(self, risk_envelope=frozen_mapping(scales.intersect(self.risk_envelope, ceilings)))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Delegation:
|
||||
id: str
|
||||
decision_class: str
|
||||
scope: Mapping[str, Any]
|
||||
mode_name: str
|
||||
obligations: tuple[str, ...]
|
||||
evidence_required: tuple[str, ...]
|
||||
risk_envelope: Mapping[str, Any]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> Delegation:
|
||||
return cls(
|
||||
id=str(data["id"]),
|
||||
decision_class=str(data["decision_class"]),
|
||||
scope=frozen_mapping(data.get("scope")),
|
||||
mode_name=str(data.get("mode")),
|
||||
obligations=frozen_tuple(data.get("obligations")),
|
||||
evidence_required=frozen_tuple(data.get("evidence_required")),
|
||||
risk_envelope=frozen_mapping(data.get("risk_envelope")),
|
||||
)
|
||||
|
||||
@property
|
||||
def specificity(self) -> int:
|
||||
return len(self.scope)
|
||||
|
||||
def covers(self, request: Request) -> bool:
|
||||
outcome = Outcome.from_mode(self.mode_name)
|
||||
return (
|
||||
outcome is not None
|
||||
and outcome.grants
|
||||
and self.decision_class == request.decision_class
|
||||
and all(request.scope.get(key) == value for key, value in self.scope.items())
|
||||
)
|
||||
|
||||
def as_grant(self) -> Grant:
|
||||
return Grant(
|
||||
source=self.id,
|
||||
outcome=Outcome.from_mode(self.mode_name),
|
||||
obligations=self.obligations,
|
||||
evidence_required=self.evidence_required,
|
||||
risk_envelope=self.risk_envelope,
|
||||
)
|
||||
|
||||
|
||||
def select_delegation(registry: Iterable[Delegation], request: Request) -> Delegation | None:
|
||||
"""Most specific covering delegation; ties broken by id so registry order never matters."""
|
||||
candidates = sorted((d for d in registry if d.covers(request)), key=lambda d: (-d.specificity, d.id))
|
||||
return candidates[0] if candidates else None
|
||||
152
src/emergent_security/envelope.py
Normal file
152
src/emergent_security/envelope.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""The default envelope (canon A6): which decision classes an L0 agent may decide alone."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
import yaml
|
||||
|
||||
from emergent_security.constitution import RULE_IDS
|
||||
from emergent_security.model import Outcome, frozen_mapping, frozen_tuple, same_value
|
||||
from emergent_security.scales import RiskScales
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Row:
|
||||
"""A class rule resolved against a request's attributes."""
|
||||
|
||||
class_id: str
|
||||
mode_name: str
|
||||
rules: tuple[str, ...]
|
||||
obligations: tuple[str, ...]
|
||||
evidence_required: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def mode(self) -> Outcome | None:
|
||||
return Outcome.from_mode(self.mode_name)
|
||||
|
||||
@property
|
||||
def constitutional(self) -> bool:
|
||||
"""Deny and root rows apply in every scope and no delegation overrides them."""
|
||||
return self.mode in (Outcome.DENY, Outcome.ROOT_AUTHORIZATION_REQUIRED)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Variant:
|
||||
when: Mapping[str, Any]
|
||||
mode_name: str
|
||||
rules: tuple[str, ...] | None
|
||||
obligations: tuple[str, ...] | None
|
||||
evidence_required: tuple[str, ...] | None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> Variant:
|
||||
def optional(key: str) -> tuple[str, ...] | None:
|
||||
return frozen_tuple(data[key]) if key in data else None
|
||||
|
||||
return cls(
|
||||
when=frozen_mapping(data.get("when")),
|
||||
mode_name=str(data.get("mode")),
|
||||
rules=optional("rules"),
|
||||
obligations=optional("obligations"),
|
||||
evidence_required=optional("evidence_required"),
|
||||
)
|
||||
|
||||
def matches(self, attributes: Mapping[str, Any]) -> bool:
|
||||
return all(same_value(attributes.get(key), value) for key, value in self.when.items())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClassRule:
|
||||
id: str
|
||||
title: str
|
||||
mode_name: str
|
||||
rules: tuple[str, ...]
|
||||
obligations: tuple[str, ...]
|
||||
evidence_required: tuple[str, ...]
|
||||
variants: tuple[Variant, ...]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> ClassRule:
|
||||
return cls(
|
||||
id=str(data["id"]),
|
||||
title=str(data.get("title", "")),
|
||||
mode_name=str(data.get("mode")),
|
||||
rules=frozen_tuple(data.get("rules")),
|
||||
obligations=frozen_tuple(data.get("obligations")),
|
||||
evidence_required=frozen_tuple(data.get("evidence_required")),
|
||||
variants=tuple(Variant.from_dict(v) for v in data.get("variants") or ()),
|
||||
)
|
||||
|
||||
def resolve(self, attributes: Mapping[str, Any]) -> Row:
|
||||
"""First matching variant replaces the base mode and any fields it sets."""
|
||||
base = Row(self.id, self.mode_name, self.rules, self.obligations, self.evidence_required)
|
||||
for variant in self.variants:
|
||||
if variant.matches(attributes):
|
||||
return Row(
|
||||
class_id=self.id,
|
||||
mode_name=variant.mode_name,
|
||||
rules=base.rules if variant.rules is None else variant.rules,
|
||||
obligations=base.obligations if variant.obligations is None else variant.obligations,
|
||||
evidence_required=base.evidence_required if variant.evidence_required is None else variant.evidence_required,
|
||||
)
|
||||
return base
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DefaultEnvelope:
|
||||
id: str
|
||||
delegator: str
|
||||
applies_when: Mapping[str, Any]
|
||||
risk_envelope: Mapping[str, Any]
|
||||
classes: tuple[ClassRule, ...]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> DefaultEnvelope:
|
||||
return cls(
|
||||
id=str(data["id"]),
|
||||
delegator=str(data.get("delegator", "task_assigner")),
|
||||
applies_when=frozen_mapping(data.get("applies_when")),
|
||||
risk_envelope=frozen_mapping(data.get("risk_envelope")),
|
||||
classes=tuple(ClassRule.from_dict(c) for c in data.get("classes") or ()),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> DefaultEnvelope:
|
||||
with Path(path).open(encoding="utf-8") as handle:
|
||||
return cls.from_dict(yaml.safe_load(handle))
|
||||
|
||||
@property
|
||||
def class_ids(self) -> tuple[str, ...]:
|
||||
return tuple(rule.id for rule in self.classes)
|
||||
|
||||
def _class(self, class_id: str | None) -> ClassRule | None:
|
||||
return next((rule for rule in self.classes if rule.id == class_id), None)
|
||||
|
||||
def row(self, class_id: str | None, attributes: Mapping[str, Any] | None = None) -> Row | None:
|
||||
rule = self._class(class_id)
|
||||
return rule.resolve(attributes or {}) if rule else None
|
||||
|
||||
def all_rules(self, class_id: str) -> set[str]:
|
||||
rule = self._class(class_id)
|
||||
if rule is None:
|
||||
return set()
|
||||
return set(rule.rules).union(*(v.rules or () for v in rule.variants))
|
||||
|
||||
def applies_to(self, scope: Mapping[str, Any]) -> bool:
|
||||
return all(scope.get(key) == value for key, value in self.applies_when.items())
|
||||
|
||||
def validate(self, scales: RiskScales) -> list[str]:
|
||||
errors: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for rule in self.classes:
|
||||
if rule.id in seen:
|
||||
errors.append(f"duplicate class id: {rule.id}")
|
||||
seen.add(rule.id)
|
||||
modes = [rule.mode_name, *(v.mode_name for v in rule.variants)]
|
||||
errors += [f"{rule.id}: unknown mode {m}" for m in modes if Outcome.from_mode(m) is None]
|
||||
errors += [f"{rule.id}: unknown rule {r}" for r in sorted(self.all_rules(rule.id) - RULE_IDS)]
|
||||
errors += scales.check(self.risk_envelope, f"{self.id} risk_envelope")
|
||||
return errors
|
||||
33
src/emergent_security/memory.py
Normal file
33
src/emergent_security/memory.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Security memory (canon B5). Memory informs; negative memory blocks until cleared (C10)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from emergent_security.model import Request, frozen_mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryEntry:
|
||||
decision_class: str
|
||||
scope: Mapping[str, Any]
|
||||
polarity: str
|
||||
cleared: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> MemoryEntry:
|
||||
return cls(
|
||||
decision_class=str(data["decision_class"]),
|
||||
scope=frozen_mapping(data.get("scope")),
|
||||
polarity=str(data.get("polarity", "positive")),
|
||||
cleared=bool(data.get("cleared", False)),
|
||||
)
|
||||
|
||||
def blocks(self, request: Request) -> bool:
|
||||
return (
|
||||
self.polarity == "negative"
|
||||
and not self.cleared
|
||||
and self.decision_class == request.decision_class
|
||||
and all(request.scope.get(key) == value for key, value in self.scope.items())
|
||||
)
|
||||
107
src/emergent_security/model.py
Normal file
107
src/emergent_security/model.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Pure data types shared by every module: the request, the decision and its outcome."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
|
||||
def same_value(left: Any, right: Any) -> bool:
|
||||
"""Type-strict equality, so that YAML `true` never matches `1` or `"true"`."""
|
||||
return type(left) is type(right) and left == right
|
||||
|
||||
|
||||
def frozen_mapping(value: Mapping[str, Any] | None) -> Mapping[str, Any]:
|
||||
return MappingProxyType(dict(value or {}))
|
||||
|
||||
|
||||
def frozen_tuple(value: Iterable[str] | None) -> tuple[str, ...]:
|
||||
return tuple(value or ())
|
||||
|
||||
|
||||
class Outcome(str, Enum):
|
||||
AUTONOMOUS = "AUTONOMOUS"
|
||||
AUTONOMOUS_WITH_OBLIGATIONS = "AUTONOMOUS_WITH_OBLIGATIONS"
|
||||
ESCALATE = "ESCALATE"
|
||||
DENY = "DENY"
|
||||
ROOT_AUTHORIZATION_REQUIRED = "ROOT_AUTHORIZATION_REQUIRED"
|
||||
|
||||
@classmethod
|
||||
def from_mode(cls, mode: Any) -> Outcome | None:
|
||||
"""Map an envelope `mode` string to an Outcome; None for unknown modes."""
|
||||
try:
|
||||
return cls(str(mode).upper())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def grants(self) -> bool:
|
||||
return self in (Outcome.AUTONOMOUS, Outcome.AUTONOMOUS_WITH_OBLIGATIONS)
|
||||
|
||||
@property
|
||||
def effect(self) -> str:
|
||||
if self.grants:
|
||||
return "allow"
|
||||
if self is Outcome.DENY:
|
||||
return "deny"
|
||||
return "escalate"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Request:
|
||||
"""A privileged effect an agent intends, described independently of any credential (A4)."""
|
||||
|
||||
request_id: str
|
||||
principal: str | None
|
||||
decision_class: str | None
|
||||
scope: Mapping[str, Any]
|
||||
attributes: Mapping[str, Any]
|
||||
risk: Mapping[str, Any]
|
||||
evidence: frozenset[str]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> Request:
|
||||
return cls(
|
||||
request_id=str(data.get("request_id", "")),
|
||||
principal=data.get("principal"),
|
||||
decision_class=data.get("decision_class"),
|
||||
scope=frozen_mapping(data.get("scope")),
|
||||
attributes=frozen_mapping(data.get("attributes")),
|
||||
risk=frozen_mapping(data.get("risk")),
|
||||
evidence=frozenset(data.get("evidence") or ()),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
"""Decision record (canon B2). `missing` lists what stands between the request and an allow."""
|
||||
|
||||
request_id: str
|
||||
decision_class: str | None
|
||||
outcome: Outcome
|
||||
blocking_condition: str | None = None
|
||||
route_to: str | None = None
|
||||
rules: tuple[str, ...] = ()
|
||||
obligations: tuple[str, ...] = ()
|
||||
missing: tuple[str, ...] = ()
|
||||
source: str | None = None
|
||||
|
||||
@property
|
||||
def effect(self) -> str:
|
||||
return self.outcome.effect
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"request_id": self.request_id,
|
||||
"decision_class": self.decision_class,
|
||||
"outcome": self.outcome.value,
|
||||
"effect": self.effect,
|
||||
"blocking_condition": self.blocking_condition,
|
||||
"route_to": self.route_to,
|
||||
"rules": list(self.rules),
|
||||
"obligations": list(self.obligations),
|
||||
"missing": list(self.missing),
|
||||
"source": self.source,
|
||||
}
|
||||
81
src/emergent_security/scales.py
Normal file
81
src/emergent_security/scales.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Ordinal risk scales (canon B1 risk dimensions, A3 step 8).
|
||||
|
||||
Every comparison fails safe: a value that cannot be ranked is treated as exceeding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
import yaml
|
||||
|
||||
from emergent_security.model import same_value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RiskScales:
|
||||
scales: Mapping[str, tuple[Any, ...]]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> RiskScales:
|
||||
return cls(scales={dim: tuple(levels) for dim, levels in data["scales"].items()})
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> RiskScales:
|
||||
with Path(path).open(encoding="utf-8") as handle:
|
||||
return cls.from_dict(yaml.safe_load(handle))
|
||||
|
||||
@property
|
||||
def dimensions(self) -> tuple[str, ...]:
|
||||
return tuple(self.scales)
|
||||
|
||||
def rank(self, dimension: str, value: Any) -> int | None:
|
||||
for index, level in enumerate(self.scales.get(dimension, ())):
|
||||
if same_value(level, value):
|
||||
return index
|
||||
return None
|
||||
|
||||
def within(self, dimension: str, value: Any, ceiling: Any) -> bool:
|
||||
value_rank, ceiling_rank = self.rank(dimension, value), self.rank(dimension, ceiling)
|
||||
return value_rank is not None and ceiling_rank is not None and value_rank <= ceiling_rank
|
||||
|
||||
def narrower(self, dimension: str, left: Any, right: Any) -> Any:
|
||||
"""The more restrictive of two ceilings; an unrankable value wins so that checks fail safe."""
|
||||
left_rank, right_rank = self.rank(dimension, left), self.rank(dimension, right)
|
||||
if left_rank is None:
|
||||
return left
|
||||
if right_rank is None:
|
||||
return right
|
||||
return left if left_rank <= right_rank else right
|
||||
|
||||
def intersect(self, *ceilings: Mapping[str, Any]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for mapping in ceilings:
|
||||
for dimension, value in mapping.items():
|
||||
result[dimension] = self.narrower(dimension, result[dimension], value) if dimension in result else value
|
||||
return result
|
||||
|
||||
def exceeded(self, risk: Mapping[str, Any], ceilings: Mapping[str, Any], *, declared_only: bool = False) -> tuple[str, ...]:
|
||||
"""Dimensions where `risk` is above `ceilings`, in scale order.
|
||||
|
||||
By default a dimension the ceilings declare but the request does not classify
|
||||
counts as exceeded (A3 step 8). With declared_only, only dimensions the request
|
||||
states are checked.
|
||||
"""
|
||||
ordered = [d for d in self.dimensions if d in ceilings] + sorted(d for d in ceilings if d not in self.scales)
|
||||
return tuple(
|
||||
dimension
|
||||
for dimension in ordered
|
||||
if not (declared_only and risk.get(dimension) is None)
|
||||
and not self.within(dimension, risk.get(dimension), ceilings[dimension])
|
||||
)
|
||||
|
||||
def check(self, mapping: Mapping[str, Any], where: str) -> list[str]:
|
||||
"""Validation errors for a ceilings mapping."""
|
||||
return [
|
||||
f"{where}: unknown value for {dimension}: {value}"
|
||||
for dimension, value in mapping.items()
|
||||
if self.rank(dimension, value) is None
|
||||
]
|
||||
168
src/emergent_security/tenant.py
Normal file
168
src/emergent_security/tenant.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Platform tenant envelopes (B10), tenant governance (B11) and their invariants (C14, G7).
|
||||
|
||||
The platform envelope is the outer bound. Tenant governance can only narrow it:
|
||||
effective envelope = platform tenant envelope ∩ tenant governance envelope.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from emergent_security.constitution import PLATFORM_TENANT_BOUND, TENANT_GOVERNANCE
|
||||
from emergent_security.delegation import Grant
|
||||
from emergent_security.model import Outcome, Request, frozen_mapping, frozen_tuple
|
||||
from emergent_security.scales import RiskScales
|
||||
|
||||
GOVERNANCE_MODES = ("auto", "self_governed")
|
||||
DEADLINE_ACTIONS = ("escalate_to_next_admin", "escalate_to_platform") # never auto-approve
|
||||
GOVERNANCE_FIELDS = frozenset({
|
||||
"apiVersion", "kind", "tenant", "mode", "bounded_by", "established_by", "restrictions",
|
||||
"human_decision_classes", "escalation_target", "deadline_default", "on_deadline",
|
||||
})
|
||||
RESTRICTION_FIELDS = frozenset({"prohibitions", "ceilings"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlatformTenantEnvelope:
|
||||
id: str
|
||||
tenant: str
|
||||
capabilities: tuple[str, ...]
|
||||
delegated: Mapping[str, Mapping[str, Any]]
|
||||
ceilings: Mapping[str, Any]
|
||||
prohibitions: tuple[str, ...]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> PlatformTenantEnvelope:
|
||||
return cls(
|
||||
id=str(data["id"]),
|
||||
tenant=str(data["tenant"]),
|
||||
capabilities=frozen_tuple(data.get("capabilities")),
|
||||
delegated=frozen_mapping({k: frozen_mapping(v) for k, v in (data.get("delegated") or {}).items()}),
|
||||
ceilings=frozen_mapping(data.get("ceilings")),
|
||||
prohibitions=frozen_tuple(data.get("prohibitions")),
|
||||
)
|
||||
|
||||
def grant_for(self, class_id: str | None) -> Grant | None:
|
||||
spec = self.delegated.get(class_id) if class_id else None
|
||||
outcome = Outcome.from_mode(spec.get("mode")) if spec else None
|
||||
if outcome is None or not outcome.grants:
|
||||
return None
|
||||
return Grant(
|
||||
source=self.id,
|
||||
outcome=outcome,
|
||||
obligations=frozen_tuple(spec.get("obligations")),
|
||||
evidence_required=frozen_tuple(spec.get("evidence_required")),
|
||||
risk_envelope=self.ceilings,
|
||||
)
|
||||
|
||||
def validate(self, scales: RiskScales) -> list[str]:
|
||||
errors = [f"delegated class outside capabilities: {c}" for c in self.delegated if c not in self.capabilities]
|
||||
errors += [f"both delegated and prohibited: {c}" for c in self.delegated if c in self.prohibitions]
|
||||
errors += [
|
||||
f"{c}: delegated mode must grant: {spec.get('mode')}"
|
||||
for c, spec in self.delegated.items()
|
||||
if not (Outcome.from_mode(spec.get("mode")) or Outcome.DENY).grants
|
||||
]
|
||||
return errors + scales.check(self.ceilings, f"{self.id} ceilings")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantGovernance:
|
||||
tenant: str
|
||||
mode: str
|
||||
bounded_by: str
|
||||
prohibitions: tuple[str, ...]
|
||||
ceilings: Mapping[str, Any]
|
||||
human_decision_classes: tuple[str, ...]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> TenantGovernance:
|
||||
restrictions = data.get("restrictions") or {}
|
||||
return cls(
|
||||
tenant=str(data.get("tenant")),
|
||||
mode=str(data.get("mode", "auto")),
|
||||
bounded_by=str(data.get("bounded_by")),
|
||||
prohibitions=frozen_tuple(restrictions.get("prohibitions")),
|
||||
ceilings=frozen_mapping(restrictions.get("ceilings")),
|
||||
human_decision_classes=frozen_tuple(data.get("human_decision_classes")),
|
||||
)
|
||||
|
||||
@property
|
||||
def self_governed(self) -> bool:
|
||||
return self.mode == "self_governed"
|
||||
|
||||
|
||||
def validate_tenant_governance(
|
||||
data: Mapping[str, Any], platform: PlatformTenantEnvelope, scales: RiskScales
|
||||
) -> list[str]:
|
||||
"""All reasons a tenant governance document would widen, or fail to bound, the platform envelope.
|
||||
|
||||
Works on the raw document so that unknown (widening) fields are caught before parsing.
|
||||
A non-empty result means the document is rejected as a whole.
|
||||
"""
|
||||
errors = [f"field not allowed: {k} (tenant governance may only narrow)" for k in sorted(set(data) - GOVERNANCE_FIELDS)]
|
||||
mode = data.get("mode")
|
||||
restrictions = data.get("restrictions") or {}
|
||||
human_classes = data.get("human_decision_classes") or []
|
||||
|
||||
if mode not in GOVERNANCE_MODES:
|
||||
errors.append(f"mode must be one of {', '.join(GOVERNANCE_MODES)}: {mode}")
|
||||
if data.get("tenant") != platform.tenant:
|
||||
errors.append(f"tenant does not match platform envelope: {data.get('tenant')}")
|
||||
if data.get("bounded_by") != platform.id:
|
||||
errors.append(f"bounded_by does not match platform envelope {platform.id}: {data.get('bounded_by')}")
|
||||
if not str(data.get("established_by", "")).startswith("human://"):
|
||||
errors.append(f"established_by must be a human principal: {data.get('established_by')}")
|
||||
if data.get("on_deadline") not in DEADLINE_ACTIONS:
|
||||
errors.append(f"on_deadline must be one of {', '.join(DEADLINE_ACTIONS)}: {data.get('on_deadline')}")
|
||||
if mode == "auto" and (restrictions or human_classes):
|
||||
errors.append("auto mode carries no restrictions; use self_governed")
|
||||
|
||||
errors += [f"restriction not allowed: {k}" for k in sorted(set(restrictions) - RESTRICTION_FIELDS)]
|
||||
for dimension, value in (restrictions.get("ceilings") or {}).items():
|
||||
if scales.rank(dimension, value) is None:
|
||||
errors.append(f"unknown value for {dimension}: {value}")
|
||||
elif dimension in platform.ceilings and not scales.within(dimension, value, platform.ceilings[dimension]):
|
||||
errors.append(f"ceiling {dimension}={value} exceeds platform ceiling {platform.ceilings[dimension]}")
|
||||
errors += [f"human decision class outside platform capabilities: {c}" for c in human_classes if c not in platform.capabilities]
|
||||
return errors
|
||||
|
||||
|
||||
def effective_ceilings(
|
||||
platform: PlatformTenantEnvelope, governance: TenantGovernance | None, scales: RiskScales
|
||||
) -> dict[str, Any]:
|
||||
"""Recomputed on every call, so platform narrowing propagates and tenant restrictions persist."""
|
||||
if governance is None or not governance.self_governed:
|
||||
return dict(platform.ceilings)
|
||||
return scales.intersect(platform.ceilings, governance.ceilings)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantContext:
|
||||
platform: PlatformTenantEnvelope
|
||||
governance: TenantGovernance | None = None
|
||||
|
||||
@property
|
||||
def self_governed(self) -> bool:
|
||||
return self.governance is not None and self.governance.self_governed
|
||||
|
||||
def prohibition(self, class_id: str | None) -> tuple[str, ...]:
|
||||
"""Rule ids of an explicit prohibition on this class, or () if none."""
|
||||
if class_id in self.platform.prohibitions:
|
||||
return (PLATFORM_TENANT_BOUND,)
|
||||
if self.self_governed and class_id in self.governance.prohibitions:
|
||||
return (TENANT_GOVERNANCE,)
|
||||
return ()
|
||||
|
||||
def outside(self, request: Request, scales: RiskScales) -> tuple[str, ...]:
|
||||
"""What places the request outside the platform tenant envelope (A3 step 4); () if inside."""
|
||||
if request.decision_class not in self.platform.capabilities:
|
||||
return (f"capability:{request.decision_class}",)
|
||||
return scales.exceeded(request.risk, self.platform.ceilings, declared_only=True)
|
||||
|
||||
def ceilings(self, scales: RiskScales) -> dict[str, Any]:
|
||||
return effective_ceilings(self.platform, self.governance, scales)
|
||||
|
||||
def requires_human(self, class_id: str | None) -> bool:
|
||||
return self.self_governed and class_id in self.governance.human_decision_classes
|
||||
Loading…
Add table
Add a link
Reference in a new issue