Finish KG-WP-0003: stream completeness and live qonto observation
Classify evidence as load-bearing or attributive, draft the emission-cadence declaration for Taxonomy, treat silence as a stream finding, keep completeness separate from record richness, forbid immune memory as a state plane, and make containment proposals reconstructable to their origin. Observe real qonto-assistant audit events; deny-class completeness stays unknown until the source publishes a heartbeat. Assistant: grok Assistant-Session: 01a05ef1-9e5a-70f2-b0ff-0b05d6b38ae9
This commit is contained in:
parent
c85646dc3c
commit
9daea96c43
35 changed files with 2023 additions and 138 deletions
|
|
@ -3,7 +3,12 @@ from __future__ import annotations
|
|||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from kings_guard.contracts import ImmuneObservation, ObservationDecision
|
||||
from kings_guard.contracts import (
|
||||
EvidenceClass,
|
||||
ImmuneObservation,
|
||||
ObservationDecision,
|
||||
SecurityGenome,
|
||||
)
|
||||
|
||||
|
||||
def observation_from_audit_event(
|
||||
|
|
@ -13,8 +18,24 @@ def observation_from_audit_event(
|
|||
capability_scope: str,
|
||||
identity_binding: str,
|
||||
egress_destination: str | None,
|
||||
genome: SecurityGenome | None = None,
|
||||
evidence_class: EvidenceClass | str | None = None,
|
||||
event_class: str | None = None,
|
||||
) -> ImmuneObservation:
|
||||
"""Normalize qonto-assistant's audit stream into Kings Guard's observation contract."""
|
||||
"""Normalize qonto-assistant's audit stream into Kings Guard's observation contract.
|
||||
|
||||
Evidence class is copied from the source's declaration (the genome) or an
|
||||
explicit caller-supplied declaration. It is never inferred from the event
|
||||
body — a deny is load-bearing because the source said so, not because
|
||||
kings-guard recognized the string "deny".
|
||||
"""
|
||||
decision = ObservationDecision(str(event["decision"]))
|
||||
resolved_event_class = event_class or f"audit.{decision.value}"
|
||||
resolved_class = _declared_evidence_class(
|
||||
genome=genome,
|
||||
event_class=resolved_event_class,
|
||||
evidence_class=evidence_class,
|
||||
)
|
||||
return ImmuneObservation(
|
||||
observation_id=str(event["request_id"]),
|
||||
source_system="qonto-assistant",
|
||||
|
|
@ -25,7 +46,9 @@ def observation_from_audit_event(
|
|||
capability=capability_scope,
|
||||
resource_scope=_optional_str(event.get("capability")),
|
||||
protocol=str(event["protocol"]),
|
||||
decision=ObservationDecision(str(event["decision"])),
|
||||
decision=decision,
|
||||
evidence_class=resolved_class,
|
||||
event_class=resolved_event_class,
|
||||
deny_reason=_optional_str(event.get("deny_reason")),
|
||||
identity_binding=identity_binding,
|
||||
egress_destination=egress_destination,
|
||||
|
|
@ -36,6 +59,36 @@ def observation_from_audit_event(
|
|||
)
|
||||
|
||||
|
||||
def _declared_evidence_class(
|
||||
*,
|
||||
genome: SecurityGenome | None,
|
||||
event_class: str,
|
||||
evidence_class: EvidenceClass | str | None,
|
||||
) -> EvidenceClass:
|
||||
if evidence_class is not None:
|
||||
declared = (
|
||||
evidence_class
|
||||
if isinstance(evidence_class, EvidenceClass)
|
||||
else EvidenceClass(str(evidence_class))
|
||||
)
|
||||
if genome is not None:
|
||||
source = genome.source_for(event_class)
|
||||
if source is not None and source.evidence_class != declared:
|
||||
raise ValueError(
|
||||
f"supplied evidence class {declared.value!r} does not match "
|
||||
f"source declaration {source.evidence_class.value!r} for {event_class}"
|
||||
)
|
||||
return declared
|
||||
if genome is not None:
|
||||
source = genome.source_for(event_class)
|
||||
if source is not None:
|
||||
return source.evidence_class
|
||||
raise ValueError(
|
||||
"evidence class must be declared by the source (genome) or supplied "
|
||||
"explicitly; kings-guard does not infer it from event contents"
|
||||
)
|
||||
|
||||
|
||||
def _optional_int(value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
|
|
|||
168
src/kings_guard/cadence.py
Normal file
168
src/kings_guard/cadence.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib.resources import files
|
||||
from typing import Any
|
||||
|
||||
from kings_guard.contracts import CadenceForm, EvidenceClass
|
||||
|
||||
|
||||
def parse_timestamp(value: str) -> datetime:
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(text)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def parse_interval(value: str | int) -> timedelta:
|
||||
if isinstance(value, int):
|
||||
return timedelta(seconds=value)
|
||||
text = str(value).strip().lower()
|
||||
if text.endswith("s") and text[:-1].isdigit():
|
||||
return timedelta(seconds=int(text[:-1]))
|
||||
if text.endswith("m") and text[:-1].isdigit():
|
||||
return timedelta(minutes=int(text[:-1]))
|
||||
if text.endswith("h") and text[:-1].isdigit():
|
||||
return timedelta(hours=int(text[:-1]))
|
||||
if text.endswith("d") and text[:-1].isdigit():
|
||||
return timedelta(days=int(text[:-1]))
|
||||
if text.startswith("pt"):
|
||||
# Minimal ISO-8601 duration: PT24H, PT1H, PT30M.
|
||||
amount = text[2:]
|
||||
if amount.endswith("h") and amount[:-1].isdigit():
|
||||
return timedelta(hours=int(amount[:-1]))
|
||||
if amount.endswith("m") and amount[:-1].isdigit():
|
||||
return timedelta(minutes=int(amount[:-1]))
|
||||
if amount.endswith("s") and amount[:-1].isdigit():
|
||||
return timedelta(seconds=int(amount[:-1]))
|
||||
raise ValueError(f"unsupported interval: {value!r}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RateCadence:
|
||||
event_class: str
|
||||
evidence_class: EvidenceClass
|
||||
window: timedelta
|
||||
expected_min: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HeartbeatCadence:
|
||||
event_class: str
|
||||
covered_event_class: str
|
||||
evidence_class: EvidenceClass
|
||||
interval: timedelta
|
||||
assertion: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationCadence:
|
||||
covered_event_class: str
|
||||
evidence_class: EvidenceClass
|
||||
local_field: str
|
||||
observed_field: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmissionCadence:
|
||||
"""Runtime view of the Taxonomy draft, loaded from the local worked example.
|
||||
|
||||
This is a consumer of the draft in `specs/EmissionCadenceDeclaration.md`,
|
||||
not a competing schema. Ownership stays with Taxonomy.
|
||||
"""
|
||||
|
||||
schema_version: str
|
||||
status: str
|
||||
drafter: str
|
||||
owner: str
|
||||
source_system: str
|
||||
reference_instance: str
|
||||
rates: tuple[RateCadence, ...]
|
||||
heartbeats: tuple[HeartbeatCadence, ...]
|
||||
reconciliations: tuple[ReconciliationCadence, ...]
|
||||
|
||||
def forms(self) -> frozenset[CadenceForm]:
|
||||
forms: set[CadenceForm] = set()
|
||||
if self.rates:
|
||||
forms.add("expected-rate")
|
||||
if self.heartbeats or self.reconciliations:
|
||||
forms.add("heartbeat-or-reconciliation")
|
||||
return frozenset(forms)
|
||||
|
||||
|
||||
def load_qonto_assistant_cadence() -> EmissionCadence:
|
||||
payload = json.loads(
|
||||
files("kings_guard")
|
||||
.joinpath("fixtures")
|
||||
.joinpath("qonto_assistant_cadence.json")
|
||||
.read_text(encoding="utf-8")
|
||||
)
|
||||
return emission_cadence_from_dict(payload)
|
||||
|
||||
|
||||
def emission_cadence_from_dict(data: Mapping[str, Any]) -> EmissionCadence:
|
||||
rates: list[RateCadence] = []
|
||||
heartbeats: list[HeartbeatCadence] = []
|
||||
reconciliations: list[ReconciliationCadence] = []
|
||||
for item in data.get("sources", ()):
|
||||
evidence_class = EvidenceClass(str(item["evidence_class"]))
|
||||
form = str(item["form"])
|
||||
if form == "expected-rate":
|
||||
rates.append(
|
||||
RateCadence(
|
||||
event_class=str(item["event_class"]),
|
||||
evidence_class=evidence_class,
|
||||
window=parse_interval(item.get("window_seconds", item.get("window"))),
|
||||
expected_min=int(item["expected_min"]),
|
||||
)
|
||||
)
|
||||
continue
|
||||
if form != "heartbeat-or-reconciliation":
|
||||
raise ValueError(f"unknown cadence form: {form}")
|
||||
heartbeat = item.get("heartbeat") or {}
|
||||
if heartbeat:
|
||||
heartbeats.append(
|
||||
HeartbeatCadence(
|
||||
event_class=str(heartbeat["event_class"]),
|
||||
covered_event_class=str(item["event_class"]),
|
||||
evidence_class=evidence_class,
|
||||
interval=parse_interval(
|
||||
heartbeat.get("interval_seconds", heartbeat.get("interval"))
|
||||
),
|
||||
assertion=str(heartbeat.get("assertion", "nothing-to-report")),
|
||||
)
|
||||
)
|
||||
reconciliation = item.get("reconciliation") or {}
|
||||
if reconciliation:
|
||||
reconciliations.append(
|
||||
ReconciliationCadence(
|
||||
covered_event_class=str(item["event_class"]),
|
||||
evidence_class=evidence_class,
|
||||
local_field=str(reconciliation.get("compare_local", "source_counts")),
|
||||
observed_field=str(reconciliation.get("compare_observed", "evidence_counts")),
|
||||
)
|
||||
)
|
||||
return EmissionCadence(
|
||||
schema_version=str(data.get("schema_version", "0.1")),
|
||||
status=str(data.get("status", "taxonomy-draft")),
|
||||
drafter=str(data.get("drafter", "kings-guard")),
|
||||
owner=str(data.get("owner", "Taxonomy")),
|
||||
source_system=str(data.get("source", data.get("source_system", "unknown"))),
|
||||
reference_instance=str(data.get("reference_instance", "GH-WP-0002-T04")),
|
||||
rates=tuple(rates),
|
||||
heartbeats=tuple(heartbeats),
|
||||
reconciliations=tuple(reconciliations),
|
||||
)
|
||||
|
||||
|
||||
def count_event_classes(event_classes: Sequence[str]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for event_class in event_classes:
|
||||
counts[event_class] = counts.get(event_class, 0) + 1
|
||||
return counts
|
||||
|
|
@ -12,12 +12,36 @@ AuthorityBoundary = Literal[
|
|||
"local_service_owned",
|
||||
"requires_human_approval",
|
||||
]
|
||||
RestrictiveDirection = Literal[
|
||||
"reduce_authority",
|
||||
"require_step_up",
|
||||
"request_containment",
|
||||
"none",
|
||||
]
|
||||
CadenceForm = Literal["expected-rate", "heartbeat-or-reconciliation"]
|
||||
MemoryRuntimeDependency = Literal["forbidden"]
|
||||
|
||||
|
||||
class EvidenceClass(str, Enum):
|
||||
"""Source-declared evidence class (§9.6). Not inferred by kings-guard."""
|
||||
|
||||
LOAD_BEARING = "load-bearing"
|
||||
ATTRIBUTIVE = "attributive"
|
||||
|
||||
|
||||
class StreamCompleteness(str, Enum):
|
||||
"""Completeness of the *stream*, never richness of the record in hand."""
|
||||
|
||||
COMPLETE = "complete"
|
||||
DEGRADED = "degraded"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ObservationDecision(str, Enum):
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
ERROR = "error"
|
||||
HEARTBEAT = "heartbeat"
|
||||
|
||||
|
||||
class PostureLevel(str, Enum):
|
||||
|
|
@ -31,6 +55,16 @@ class SignalKind(str, Enum):
|
|||
POSTURE_HINT = "posture_hint"
|
||||
OBSERVATION_ALERT = "observation_alert"
|
||||
RECOVERY_REQUEST = "recovery_request"
|
||||
STREAM_COMPLETENESS = "stream_completeness"
|
||||
|
||||
|
||||
STREAM_FINDING_PREFIX = "stream:"
|
||||
|
||||
COMPLETENESS_RANK = {
|
||||
StreamCompleteness.UNKNOWN: 0,
|
||||
StreamCompleteness.DEGRADED: 1,
|
||||
StreamCompleteness.COMPLETE: 2,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -52,6 +86,34 @@ class ToleranceRule:
|
|||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeclaredEvidenceSource:
|
||||
"""A source the subject declares, including its evidence class.
|
||||
|
||||
The class is the source's declaration. kings-guard copies it onto
|
||||
observations; it does not infer it from event contents.
|
||||
"""
|
||||
|
||||
source_id: str
|
||||
source_system: str
|
||||
event_class: str
|
||||
evidence_class: EvidenceClass
|
||||
reasoning: str
|
||||
cadence_form: CadenceForm | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> "DeclaredEvidenceSource":
|
||||
cadence_form = data.get("cadence_form")
|
||||
return cls(
|
||||
source_id=str(data["source_id"]),
|
||||
source_system=str(data["source_system"]),
|
||||
event_class=str(data["event_class"]),
|
||||
evidence_class=EvidenceClass(str(data["evidence_class"])),
|
||||
reasoning=str(data["reasoning"]),
|
||||
cadence_form=str(cadence_form) if cadence_form else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SecurityGenome:
|
||||
genome_id: str
|
||||
|
|
@ -64,6 +126,7 @@ class SecurityGenome:
|
|||
permitted_egress: frozenset[str]
|
||||
data_classifications: tuple[str, ...] = ()
|
||||
tolerances: tuple[ToleranceRule, ...] = ()
|
||||
evidence_sources: tuple[DeclaredEvidenceSource, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> "SecurityGenome":
|
||||
|
|
@ -80,8 +143,18 @@ class SecurityGenome:
|
|||
tolerances=tuple(
|
||||
ToleranceRule.from_dict(item) for item in data.get("tolerances", ())
|
||||
),
|
||||
evidence_sources=tuple(
|
||||
DeclaredEvidenceSource.from_dict(item)
|
||||
for item in data.get("evidence_sources", ())
|
||||
),
|
||||
)
|
||||
|
||||
def source_for(self, event_class: str) -> DeclaredEvidenceSource | None:
|
||||
for source in self.evidence_sources:
|
||||
if source.event_class == event_class:
|
||||
return source
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImmuneObservation:
|
||||
|
|
@ -95,6 +168,8 @@ class ImmuneObservation:
|
|||
resource_scope: str | None
|
||||
protocol: str
|
||||
decision: ObservationDecision
|
||||
evidence_class: EvidenceClass
|
||||
event_class: str
|
||||
deny_reason: str | None = None
|
||||
identity_binding: str | None = None
|
||||
egress_destination: str | None = None
|
||||
|
|
@ -116,6 +191,8 @@ class ImmuneObservation:
|
|||
resource_scope=_optional_str(data.get("resource_scope")),
|
||||
protocol=str(data["protocol"]),
|
||||
decision=ObservationDecision(str(data["decision"])),
|
||||
evidence_class=EvidenceClass(str(data["evidence_class"])),
|
||||
event_class=str(data["event_class"]),
|
||||
deny_reason=_optional_str(data.get("deny_reason")),
|
||||
identity_binding=_optional_str(data.get("identity_binding")),
|
||||
egress_destination=_optional_str(data.get("egress_destination")),
|
||||
|
|
@ -126,6 +203,25 @@ class ImmuneObservation:
|
|||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StreamHeartbeat:
|
||||
"""A signed positive claim that can itself go missing (§9.6)."""
|
||||
|
||||
source_system: str
|
||||
timestamp: str
|
||||
event_class: str
|
||||
assertion: str
|
||||
counts: Mapping[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationView:
|
||||
"""Source transition counts versus observed evidence counts per event class."""
|
||||
|
||||
source_counts: Mapping[str, int]
|
||||
evidence_counts: Mapping[str, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SecurityPhenotype:
|
||||
subject_id: str
|
||||
|
|
@ -142,11 +238,22 @@ class PostureAssessment:
|
|||
posture: PostureLevel
|
||||
risk_score: int
|
||||
confidence_score: int
|
||||
stream_completeness: StreamCompleteness
|
||||
completeness_reason: str
|
||||
findings: tuple[str, ...]
|
||||
tolerated_findings: tuple[str, ...]
|
||||
rationale: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StreamAssessment:
|
||||
completeness: StreamCompleteness
|
||||
reason: str
|
||||
findings: tuple[str, ...]
|
||||
observed_counts: Mapping[str, int]
|
||||
window_end: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectorRequest:
|
||||
target_system: str
|
||||
|
|
@ -154,6 +261,10 @@ class EffectorRequest:
|
|||
authority_boundary: AuthorityBoundary
|
||||
reason: str
|
||||
requires_human_approval: bool
|
||||
originating_observation_id: str
|
||||
originating_signal_id: str
|
||||
stream_completeness: StreamCompleteness
|
||||
restrictive_direction: RestrictiveDirection = "none"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -170,12 +281,15 @@ class ImmuneSignal:
|
|||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImmuneMemoryEntry:
|
||||
"""Governed defensive learning. Not a runtime input for any other layer."""
|
||||
|
||||
memory_id: str
|
||||
subject_scope: str
|
||||
summary: str
|
||||
derived_from: tuple[str, ...]
|
||||
recommended_countermeasures: tuple[str, ...]
|
||||
confidentiality: str = "non-secret"
|
||||
runtime_input_for_other_layers: MemoryRuntimeDependency = "forbidden"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -183,13 +297,23 @@ class PostureEvaluation:
|
|||
phenotype: SecurityPhenotype
|
||||
assessment: PostureAssessment
|
||||
signals: tuple[ImmuneSignal, ...]
|
||||
stream: StreamAssessment | None = None
|
||||
|
||||
|
||||
def assessment_trust_key(assessment: PostureAssessment) -> tuple[int, int]:
|
||||
"""Order judgments so completeness outranks record richness.
|
||||
|
||||
An incomplete stream can never read as more trustworthy than a complete
|
||||
one, regardless of how well-formed the record in hand is.
|
||||
"""
|
||||
return (COMPLETENESS_RANK[assessment.stream_completeness], assessment.confidence_score)
|
||||
|
||||
|
||||
def as_jsonable(value: Any) -> Any:
|
||||
"""Convert contract objects into JSON-safe primitives."""
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if is_dataclass(value):
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
return {key: as_jsonable(item) for key, item in asdict(value).items()}
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): as_jsonable(item) for key, item in value.items()}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from dataclasses import dataclass
|
|||
from importlib.resources import files
|
||||
from typing import Any
|
||||
|
||||
from kings_guard.cadence import EmissionCadence, load_qonto_assistant_cadence
|
||||
from kings_guard.contracts import SecurityGenome
|
||||
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ class QontoAssistantPilotFixture:
|
|||
audit_event: dict[str, Any]
|
||||
normalization_hints: dict[str, str]
|
||||
source_notes: tuple[str, ...]
|
||||
evidence_class_reasoning: tuple[str, ...]
|
||||
|
||||
|
||||
def load_qonto_assistant_pilot() -> QontoAssistantPilotFixture:
|
||||
|
|
@ -21,11 +23,20 @@ def load_qonto_assistant_pilot() -> QontoAssistantPilotFixture:
|
|||
return QontoAssistantPilotFixture(
|
||||
genome=SecurityGenome.from_dict(payload["normalized_genome"]),
|
||||
audit_event=dict(payload["qonto_audit_event"]),
|
||||
normalization_hints={str(key): str(value) for key, value in payload["normalization_hints"].items()},
|
||||
normalization_hints={
|
||||
str(key): str(value) for key, value in payload["normalization_hints"].items()
|
||||
},
|
||||
source_notes=tuple(str(item) for item in payload.get("source_notes", ())),
|
||||
evidence_class_reasoning=tuple(
|
||||
str(item) for item in payload.get("evidence_class_reasoning", ())
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def load_pilot_cadence() -> EmissionCadence:
|
||||
return load_qonto_assistant_cadence()
|
||||
|
||||
|
||||
def _load_json_fixture(name: str) -> dict[str, Any]:
|
||||
fixture_path = files("kings_guard").joinpath("fixtures").joinpath(name)
|
||||
return json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||
|
|
|
|||
44
src/kings_guard/fixtures/qonto_assistant_cadence.json
Normal file
44
src/kings_guard/fixtures/qonto_assistant_cadence.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"status": "taxonomy-draft",
|
||||
"drafter": "kings-guard",
|
||||
"owner": "Taxonomy",
|
||||
"source": "qonto-assistant",
|
||||
"belongs_alongside": "security_genome",
|
||||
"reference_instance": "GH-WP-0002-T04",
|
||||
"reference_source_declaration": "approval-engine/cadence.yaml",
|
||||
"sources": [
|
||||
{
|
||||
"source_id": "qonto-assistant.audit.allow",
|
||||
"source_system": "qonto-assistant",
|
||||
"event_class": "audit.allow",
|
||||
"evidence_class": "attributive",
|
||||
"form": "expected-rate",
|
||||
"window": "24h",
|
||||
"window_seconds": 86400,
|
||||
"expected_min": 1,
|
||||
"drop_below": "finding",
|
||||
"note": "Worked example of the volume form. qonto-assistant is called sporadically, so this rate is a SHOULD illustration, not a claim that completeness of allows is currently meaningful."
|
||||
},
|
||||
{
|
||||
"source_id": "qonto-assistant.audit.deny",
|
||||
"source_system": "qonto-assistant",
|
||||
"event_class": "audit.deny",
|
||||
"evidence_class": "load-bearing",
|
||||
"form": "heartbeat-or-reconciliation",
|
||||
"rate_monitoring": "forbidden",
|
||||
"heartbeat": {
|
||||
"event_class": "audit.heartbeat",
|
||||
"interval": "24h",
|
||||
"interval_seconds": 86400,
|
||||
"assertion": "nothing-to-report",
|
||||
"missing": "finding"
|
||||
},
|
||||
"reconciliation": {
|
||||
"compare_local": "source_transition_counts.audit.deny",
|
||||
"compare_observed": "evidence_counts.audit.deny",
|
||||
"divergence": "finding"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -28,6 +28,32 @@
|
|||
"description": "Actor identity is still self-asserted until key-cape integration lands.",
|
||||
"effect": "monitor"
|
||||
}
|
||||
],
|
||||
"evidence_sources": [
|
||||
{
|
||||
"source_id": "qonto-assistant.audit.allow",
|
||||
"source_system": "qonto-assistant",
|
||||
"event_class": "audit.allow",
|
||||
"evidence_class": "attributive",
|
||||
"cadence_form": "expected-rate",
|
||||
"reasoning": "Allow records support forensic reconstruction. No control currently branches on an allow being present or absent, so the class is attributive and completeness is not claimed."
|
||||
},
|
||||
{
|
||||
"source_id": "qonto-assistant.audit.deny",
|
||||
"source_system": "qonto-assistant",
|
||||
"event_class": "audit.deny",
|
||||
"evidence_class": "load-bearing",
|
||||
"cadence_form": "heartbeat-or-reconciliation",
|
||||
"reasoning": "qonto-assistant's deny-escalation loop and kings-guard posture both branch on deny presence or absence. Statute §9.6 names denials as load-bearing. The class is this source's declaration, not an inference from the string 'deny'."
|
||||
},
|
||||
{
|
||||
"source_id": "qonto-assistant.audit.heartbeat",
|
||||
"source_system": "qonto-assistant",
|
||||
"event_class": "audit.heartbeat",
|
||||
"evidence_class": "load-bearing",
|
||||
"cadence_form": "heartbeat-or-reconciliation",
|
||||
"reasoning": "The positive nothing-to-report claim for the deny class. Rate monitoring cannot work on infrequent denials; the heartbeat is the claim that can itself go missing."
|
||||
}
|
||||
]
|
||||
},
|
||||
"qonto_audit_event": {
|
||||
|
|
@ -50,9 +76,15 @@
|
|||
"identity_binding": "self_asserted",
|
||||
"egress_destination": "qonto-thirdparty-api"
|
||||
},
|
||||
"evidence_class_reasoning": [
|
||||
"audit.deny is load-bearing because deny-escalation and posture branch on it; the source declares that class.",
|
||||
"audit.allow is attributive: forensic reconstruction only; completeness is not claimed.",
|
||||
"kings-guard copies the declared class onto the observation and does not infer it from decision=deny."
|
||||
],
|
||||
"source_notes": [
|
||||
"Derived from qonto-assistant/specs/security-genome.yaml",
|
||||
"Derived from qonto-assistant/src/qonto_assistant/contracts.py#AuditEvent",
|
||||
"Pilot chooses qonto-assistant because it already ships an audit stream, a genome record, and a fast local loop."
|
||||
"Pilot chooses qonto-assistant because it already ships an audit stream, a genome record, and a fast local loop.",
|
||||
"This JSON remains the regression fixture. Live emitted events are captured separately by kings_guard.live."
|
||||
]
|
||||
}
|
||||
|
|
|
|||
198
src/kings_guard/live.py
Normal file
198
src/kings_guard/live.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from kings_guard.adapters import observation_from_audit_event
|
||||
from kings_guard.contracts import ImmuneObservation, SecurityGenome
|
||||
|
||||
QONTO_ASSISTANT_SRC = Path("/home/worsch/qonto-assistant/src")
|
||||
QONTO_POLICY = (
|
||||
Path("/home/worsch/qonto-assistant/src/qonto_assistant/policy/qonto-v1.yaml")
|
||||
)
|
||||
QONTO_FIXTURES = Path("/home/worsch/qonto-assistant/tests/fixtures/qonto")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LiveQontoCapture:
|
||||
events: tuple[dict[str, Any], ...]
|
||||
observations: tuple[ImmuneObservation, ...]
|
||||
mapping_notes: tuple[str, ...]
|
||||
corrections_for_source: tuple[str, ...]
|
||||
|
||||
|
||||
def qonto_assistant_available() -> bool:
|
||||
return (QONTO_ASSISTANT_SRC / "qonto_assistant" / "service.py").is_file()
|
||||
|
||||
|
||||
def capture_qonto_assistant_events(genome: SecurityGenome) -> LiveQontoCapture:
|
||||
"""Drive qonto-assistant's real emit path and capture what it publishes.
|
||||
|
||||
Uses the adjacent checkout and its fixture-backed client. No Tooling
|
||||
client is opened; the source publishes its own stream.
|
||||
"""
|
||||
if not qonto_assistant_available():
|
||||
raise FileNotFoundError(
|
||||
f"qonto-assistant checkout not found at {QONTO_ASSISTANT_SRC}"
|
||||
)
|
||||
|
||||
src = str(QONTO_ASSISTANT_SRC)
|
||||
if src not in sys.path:
|
||||
sys.path.insert(0, src)
|
||||
|
||||
from qonto_assistant.audit import AuditLogger
|
||||
from qonto_assistant.contracts import ActorClaims
|
||||
from qonto_assistant.errors import PolicyDeniedError
|
||||
from qonto_assistant.policy import PolicyEngine
|
||||
from qonto_assistant.qonto_client import FixtureQontoClient
|
||||
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
service = CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=QONTO_FIXTURES),
|
||||
policy=PolicyEngine.from_file(
|
||||
QONTO_POLICY,
|
||||
required_scope="finance.qonto.read",
|
||||
enforce_scope=False,
|
||||
),
|
||||
audit_logger=AuditLogger(sink=events.append),
|
||||
rate_limiter=RateLimiter(limit=100, window_seconds=60),
|
||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||
)
|
||||
claims = ActorClaims(actor_id="kg-live-observer", tenant_id="binky", lane="green")
|
||||
|
||||
allow_payload = service.get_accounts(
|
||||
claims=claims, request_id="req-kg-live-allow", protocol="rest"
|
||||
)
|
||||
if not isinstance(allow_payload, Mapping):
|
||||
raise RuntimeError("qonto-assistant allow path did not return a payload")
|
||||
|
||||
try:
|
||||
service.list_transactions(
|
||||
claims=claims,
|
||||
request_id="req-kg-live-deny-arg-constraint",
|
||||
account_slug=None,
|
||||
page=1,
|
||||
page_size=10_000,
|
||||
window_days=31,
|
||||
status="completed",
|
||||
side=None,
|
||||
protocol="mcp",
|
||||
)
|
||||
except PolicyDeniedError:
|
||||
pass
|
||||
else:
|
||||
raise RuntimeError("expected qonto-assistant to emit a deny for oversized page_size")
|
||||
|
||||
if len(events) < 2:
|
||||
raise RuntimeError(f"expected allow and deny audit events, got {len(events)}")
|
||||
|
||||
observations = tuple(
|
||||
observation_from_audit_event(
|
||||
event,
|
||||
subject_id=genome.subject_id,
|
||||
capability_scope="finance.qonto.read",
|
||||
identity_binding="self_asserted",
|
||||
egress_destination="qonto-thirdparty-api",
|
||||
genome=genome,
|
||||
)
|
||||
for event in events
|
||||
)
|
||||
mapping_notes, corrections = _confirm_mapping(events, observations)
|
||||
return LiveQontoCapture(
|
||||
events=tuple(events),
|
||||
observations=observations,
|
||||
mapping_notes=mapping_notes,
|
||||
corrections_for_source=corrections,
|
||||
)
|
||||
|
||||
|
||||
def observations_from_jsonl(
|
||||
path: Path,
|
||||
genome: SecurityGenome,
|
||||
*,
|
||||
capability_scope: str,
|
||||
identity_binding: str,
|
||||
egress_destination: str | None,
|
||||
) -> tuple[ImmuneObservation, ...]:
|
||||
events = _read_jsonl(path)
|
||||
return tuple(
|
||||
observation_from_audit_event(
|
||||
event,
|
||||
subject_id=genome.subject_id,
|
||||
capability_scope=capability_scope,
|
||||
identity_binding=identity_binding,
|
||||
egress_destination=egress_destination,
|
||||
genome=genome,
|
||||
)
|
||||
for event in events
|
||||
)
|
||||
|
||||
|
||||
def _confirm_mapping(
|
||||
events: Sequence[Mapping[str, Any]],
|
||||
observations: Sequence[ImmuneObservation],
|
||||
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
notes: list[str] = []
|
||||
corrections: list[str] = []
|
||||
required = (
|
||||
"request_id",
|
||||
"timestamp",
|
||||
"actor",
|
||||
"tenant_id",
|
||||
"capability",
|
||||
"protocol",
|
||||
"decision",
|
||||
"policy_version",
|
||||
"latency_ms",
|
||||
)
|
||||
for event, observation in zip(events, observations, strict=True):
|
||||
missing = [field for field in required if field not in event]
|
||||
if missing:
|
||||
corrections.append(f"audit event missing fields: {missing}")
|
||||
continue
|
||||
if observation.observation_id != str(event["request_id"]):
|
||||
corrections.append("request_id mapping drifted")
|
||||
if observation.resource_scope != str(event["capability"]):
|
||||
corrections.append("capability->resource_scope mapping drifted")
|
||||
if observation.capability != "finance.qonto.read":
|
||||
corrections.append("coarse capability_scope mapping drifted")
|
||||
if observation.actor_id != str(event["actor"]):
|
||||
corrections.append("actor mapping drifted")
|
||||
if observation.decision.value != str(event["decision"]):
|
||||
corrections.append("decision mapping drifted")
|
||||
notes.append(
|
||||
f"{event['request_id']}: decision={event['decision']} "
|
||||
f"capability={event['capability']} protocol={event['protocol']} "
|
||||
f"maps to observation {observation.observation_id} "
|
||||
f"event_class={observation.event_class} "
|
||||
f"evidence_class={observation.evidence_class.value}"
|
||||
)
|
||||
if "identity_binding" not in event:
|
||||
corrections.append(
|
||||
"AuditEvent still omits identity_binding; kings-guard continues "
|
||||
"to use the genome-declared constant self_asserted"
|
||||
)
|
||||
if "egress_destination" not in event:
|
||||
corrections.append(
|
||||
"AuditEvent still omits egress_destination; kings-guard continues "
|
||||
"to use the genome-declared constant qonto-thirdparty-api"
|
||||
)
|
||||
# Deduplicate repeated correction text across events.
|
||||
unique_corrections = tuple(dict.fromkeys(corrections))
|
||||
return tuple(notes), unique_corrections
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
text = line.strip()
|
||||
if not text:
|
||||
continue
|
||||
events.append(json.loads(text))
|
||||
return events
|
||||
|
|
@ -5,7 +5,8 @@ import json
|
|||
|
||||
from kings_guard.adapters import observation_from_audit_event
|
||||
from kings_guard.contracts import as_jsonable
|
||||
from kings_guard.fixtures import load_qonto_assistant_pilot
|
||||
from kings_guard.fixtures import load_pilot_cadence, load_qonto_assistant_pilot
|
||||
from kings_guard.live import capture_qonto_assistant_events, qonto_assistant_available
|
||||
from kings_guard.posture import PostureEvaluator
|
||||
|
||||
|
||||
|
|
@ -17,32 +18,61 @@ def main() -> None:
|
|||
choices=["qonto-assistant"],
|
||||
help="Pilot bundle to evaluate.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--live",
|
||||
action="store_true",
|
||||
help="Observe real events emitted by qonto-assistant's AuditLogger (adjacent checkout).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.pilot != "qonto-assistant":
|
||||
raise SystemExit(f"Unsupported pilot: {args.pilot}")
|
||||
|
||||
fixture = load_qonto_assistant_pilot()
|
||||
observation = observation_from_audit_event(
|
||||
fixture.audit_event,
|
||||
subject_id=fixture.normalization_hints["subject_id"],
|
||||
capability_scope=fixture.normalization_hints["capability_scope"],
|
||||
identity_binding=fixture.normalization_hints["identity_binding"],
|
||||
egress_destination=fixture.normalization_hints["egress_destination"],
|
||||
)
|
||||
evaluation = PostureEvaluator().evaluate(fixture.genome, observation)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"pilot": args.pilot,
|
||||
"source_notes": list(fixture.source_notes),
|
||||
"observation": as_jsonable(observation),
|
||||
"evaluation": as_jsonable(evaluation),
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
cadence = load_pilot_cadence()
|
||||
evaluator = PostureEvaluator()
|
||||
|
||||
if args.live:
|
||||
if not qonto_assistant_available():
|
||||
raise SystemExit("qonto-assistant checkout is not available for live observation")
|
||||
capture = capture_qonto_assistant_events(fixture.genome)
|
||||
observation = capture.observations[-1]
|
||||
now = observation.timestamp
|
||||
evaluation = evaluator.evaluate_with_stream(
|
||||
fixture.genome,
|
||||
observation,
|
||||
cadence,
|
||||
now=now,
|
||||
observations=capture.observations,
|
||||
)
|
||||
)
|
||||
payload = {
|
||||
"pilot": args.pilot,
|
||||
"mode": "live",
|
||||
"source_notes": list(fixture.source_notes),
|
||||
"mapping_notes": list(capture.mapping_notes),
|
||||
"corrections_for_source": list(capture.corrections_for_source),
|
||||
"observation": as_jsonable(observation),
|
||||
"evaluation": as_jsonable(evaluation),
|
||||
}
|
||||
else:
|
||||
observation = observation_from_audit_event(
|
||||
fixture.audit_event,
|
||||
subject_id=fixture.normalization_hints["subject_id"],
|
||||
capability_scope=fixture.normalization_hints["capability_scope"],
|
||||
identity_binding=fixture.normalization_hints["identity_binding"],
|
||||
egress_destination=fixture.normalization_hints["egress_destination"],
|
||||
genome=fixture.genome,
|
||||
)
|
||||
evaluation = evaluator.evaluate(fixture.genome, observation)
|
||||
payload = {
|
||||
"pilot": args.pilot,
|
||||
"mode": "fixture",
|
||||
"source_notes": list(fixture.source_notes),
|
||||
"observation": as_jsonable(observation),
|
||||
"evaluation": as_jsonable(evaluation),
|
||||
}
|
||||
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from kings_guard.cadence import EmissionCadence
|
||||
from kings_guard.contracts import (
|
||||
STREAM_FINDING_PREFIX,
|
||||
EffectorRequest,
|
||||
ImmuneObservation,
|
||||
ImmuneSignal,
|
||||
PostureAssessment,
|
||||
PostureEvaluation,
|
||||
PostureLevel,
|
||||
ReconciliationView,
|
||||
RestrictiveDirection,
|
||||
SecurityGenome,
|
||||
SecurityPhenotype,
|
||||
SignalKind,
|
||||
StreamAssessment,
|
||||
StreamCompleteness,
|
||||
StreamHeartbeat,
|
||||
)
|
||||
from kings_guard.stream import evaluate_stream as assess_stream
|
||||
|
||||
CRITICAL_FINDINGS = frozenset(
|
||||
{
|
||||
|
|
@ -39,13 +49,70 @@ RISK_WEIGHTS = {
|
|||
"control_plane_error": 20,
|
||||
}
|
||||
|
||||
UNKNOWN_STREAM_REASON = (
|
||||
"per-observation evaluation cannot vouch for stream completeness; "
|
||||
"the record in hand was scored for richness only"
|
||||
)
|
||||
|
||||
|
||||
class PostureEvaluator:
|
||||
def evaluate(self, genome: SecurityGenome, observation: ImmuneObservation) -> PostureEvaluation:
|
||||
def evaluate(
|
||||
self,
|
||||
genome: SecurityGenome,
|
||||
observation: ImmuneObservation,
|
||||
*,
|
||||
stream: StreamAssessment | None = None,
|
||||
) -> PostureEvaluation:
|
||||
phenotype = self._derive_phenotype(genome, observation)
|
||||
assessment = self._assess(phenotype, observation)
|
||||
signals = self._build_signals(observation, assessment)
|
||||
return PostureEvaluation(phenotype=phenotype, assessment=assessment, signals=signals)
|
||||
assessment = self._assess(phenotype, observation, stream=stream)
|
||||
signals = self._build_signals(observation, assessment, stream=stream)
|
||||
return PostureEvaluation(
|
||||
phenotype=phenotype,
|
||||
assessment=assessment,
|
||||
signals=signals,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
def evaluate_stream(
|
||||
self,
|
||||
observations: Sequence[ImmuneObservation],
|
||||
cadence: EmissionCadence,
|
||||
*,
|
||||
now: str,
|
||||
watching_since: str | None = None,
|
||||
heartbeats: Sequence[StreamHeartbeat] = (),
|
||||
reconciliation: ReconciliationView | None = None,
|
||||
) -> StreamAssessment:
|
||||
return assess_stream(
|
||||
observations,
|
||||
cadence,
|
||||
now=now,
|
||||
watching_since=watching_since,
|
||||
heartbeats=heartbeats,
|
||||
reconciliation=reconciliation,
|
||||
)
|
||||
|
||||
def evaluate_with_stream(
|
||||
self,
|
||||
genome: SecurityGenome,
|
||||
observation: ImmuneObservation,
|
||||
cadence: EmissionCadence,
|
||||
*,
|
||||
now: str,
|
||||
observations: Sequence[ImmuneObservation] | None = None,
|
||||
watching_since: str | None = None,
|
||||
heartbeats: Sequence[StreamHeartbeat] = (),
|
||||
reconciliation: ReconciliationView | None = None,
|
||||
) -> PostureEvaluation:
|
||||
stream = self.evaluate_stream(
|
||||
observations if observations is not None else (observation,),
|
||||
cadence,
|
||||
now=now,
|
||||
watching_since=watching_since,
|
||||
heartbeats=heartbeats,
|
||||
reconciliation=reconciliation,
|
||||
)
|
||||
return self.evaluate(genome, observation, stream=stream)
|
||||
|
||||
def _derive_phenotype(
|
||||
self,
|
||||
|
|
@ -55,6 +122,17 @@ class PostureEvaluator:
|
|||
findings: list[str] = []
|
||||
tolerated: list[str] = []
|
||||
|
||||
if observation.decision is observation.decision.HEARTBEAT:
|
||||
return SecurityPhenotype(
|
||||
subject_id=observation.subject_id,
|
||||
tenant_id=observation.tenant_id,
|
||||
observed_capability=observation.capability,
|
||||
protocol=observation.protocol,
|
||||
decision=observation.decision,
|
||||
active_findings=(),
|
||||
tolerated_findings=(),
|
||||
)
|
||||
|
||||
if observation.tenant_id != genome.tenant_id:
|
||||
findings.append("tenant_mismatch")
|
||||
if observation.capability not in genome.permitted_capabilities:
|
||||
|
|
@ -95,6 +173,8 @@ class PostureEvaluator:
|
|||
self,
|
||||
phenotype: SecurityPhenotype,
|
||||
observation: ImmuneObservation,
|
||||
*,
|
||||
stream: StreamAssessment | None,
|
||||
) -> PostureAssessment:
|
||||
findings = set(phenotype.active_findings)
|
||||
|
||||
|
|
@ -115,6 +195,7 @@ class PostureEvaluator:
|
|||
risk_score = max(risk_score, 95)
|
||||
risk_score = min(risk_score, 100)
|
||||
|
||||
# Richness of the record received — never of the stream.
|
||||
confidence_score = 70
|
||||
if observation.policy_version is not None:
|
||||
confidence_score += 10
|
||||
|
|
@ -124,15 +205,26 @@ class PostureEvaluator:
|
|||
confidence_score += 5
|
||||
confidence_score = min(confidence_score, 95)
|
||||
|
||||
if stream is None:
|
||||
completeness = StreamCompleteness.UNKNOWN
|
||||
completeness_reason = UNKNOWN_STREAM_REASON
|
||||
else:
|
||||
completeness = stream.completeness
|
||||
completeness_reason = stream.reason
|
||||
|
||||
rationale = _build_rationale(
|
||||
posture=posture,
|
||||
findings=phenotype.active_findings,
|
||||
tolerated_findings=phenotype.tolerated_findings,
|
||||
completeness=completeness,
|
||||
completeness_reason=completeness_reason,
|
||||
)
|
||||
return PostureAssessment(
|
||||
posture=posture,
|
||||
risk_score=risk_score,
|
||||
confidence_score=confidence_score,
|
||||
stream_completeness=completeness,
|
||||
completeness_reason=completeness_reason,
|
||||
findings=phenotype.active_findings,
|
||||
tolerated_findings=phenotype.tolerated_findings,
|
||||
rationale=rationale,
|
||||
|
|
@ -142,23 +234,47 @@ class PostureEvaluator:
|
|||
self,
|
||||
observation: ImmuneObservation,
|
||||
assessment: PostureAssessment,
|
||||
*,
|
||||
stream: StreamAssessment | None,
|
||||
) -> tuple[ImmuneSignal, ...]:
|
||||
if assessment.posture is PostureLevel.HEALTHY:
|
||||
return ()
|
||||
signals: list[ImmuneSignal] = []
|
||||
if assessment.posture is not PostureLevel.HEALTHY:
|
||||
if observation.source_system == "qonto-assistant":
|
||||
signals.append(self._build_qonto_pilot_signal(observation, assessment))
|
||||
else:
|
||||
signals.append(
|
||||
ImmuneSignal(
|
||||
signal_id=f"sig:{observation.observation_id}",
|
||||
signal_kind=SignalKind.OBSERVATION_ALERT,
|
||||
posture=assessment.posture,
|
||||
summary=assessment.rationale,
|
||||
target_system=observation.source_system,
|
||||
findings=assessment.findings,
|
||||
metadata={"source_system": observation.source_system},
|
||||
)
|
||||
)
|
||||
if stream is not None and stream.findings:
|
||||
signals.append(self._build_stream_signal(observation, assessment, stream))
|
||||
return tuple(signals)
|
||||
|
||||
if observation.source_system == "qonto-assistant":
|
||||
return (self._build_qonto_pilot_signal(observation, assessment),)
|
||||
|
||||
signal = ImmuneSignal(
|
||||
signal_id=f"sig:{observation.observation_id}",
|
||||
signal_kind=SignalKind.OBSERVATION_ALERT,
|
||||
def _build_stream_signal(
|
||||
self,
|
||||
observation: ImmuneObservation,
|
||||
assessment: PostureAssessment,
|
||||
stream: StreamAssessment,
|
||||
) -> ImmuneSignal:
|
||||
return ImmuneSignal(
|
||||
signal_id=f"sig:stream:{observation.observation_id}",
|
||||
signal_kind=SignalKind.STREAM_COMPLETENESS,
|
||||
posture=assessment.posture,
|
||||
summary=assessment.rationale,
|
||||
summary=stream.reason,
|
||||
target_system=observation.source_system,
|
||||
findings=assessment.findings,
|
||||
metadata={"source_system": observation.source_system},
|
||||
findings=stream.findings,
|
||||
metadata={
|
||||
"stream_completeness": stream.completeness.value,
|
||||
"finding_class": "stream",
|
||||
},
|
||||
)
|
||||
return (signal,)
|
||||
|
||||
def _build_qonto_pilot_signal(
|
||||
self,
|
||||
|
|
@ -167,19 +283,22 @@ class PostureEvaluator:
|
|||
) -> ImmuneSignal:
|
||||
if "credential_exfil_probe" in assessment.findings:
|
||||
action = "lock_actor_temporarily"
|
||||
direction: RestrictiveDirection = "reduce_authority"
|
||||
reason = (
|
||||
"Observed a credential-exfil deny signal; qonto-assistant should activate "
|
||||
"its fast local loop lockout and preserve metadata-only evidence."
|
||||
)
|
||||
else:
|
||||
action = "tighten_actor_scrutiny"
|
||||
direction = "require_step_up"
|
||||
reason = (
|
||||
"Observed repeated policy-boundary pressure; qonto-assistant should tighten "
|
||||
"local scrutiny without delegating final authorization to kings-guard."
|
||||
)
|
||||
|
||||
signal_id = f"sig:{observation.observation_id}"
|
||||
return ImmuneSignal(
|
||||
signal_id=f"sig:{observation.observation_id}",
|
||||
signal_id=signal_id,
|
||||
signal_kind=SignalKind.POSTURE_HINT,
|
||||
posture=assessment.posture,
|
||||
summary=reason,
|
||||
|
|
@ -192,6 +311,10 @@ class PostureEvaluator:
|
|||
authority_boundary="advisory_only",
|
||||
reason=reason,
|
||||
requires_human_approval=False,
|
||||
originating_observation_id=observation.observation_id,
|
||||
originating_signal_id=signal_id,
|
||||
stream_completeness=assessment.stream_completeness,
|
||||
restrictive_direction=direction,
|
||||
),
|
||||
EffectorRequest(
|
||||
target_system="state-hub",
|
||||
|
|
@ -199,11 +322,16 @@ class PostureEvaluator:
|
|||
authority_boundary="metadata_only",
|
||||
reason="Preserve posture evidence without copying secret values.",
|
||||
requires_human_approval=False,
|
||||
originating_observation_id=observation.observation_id,
|
||||
originating_signal_id=signal_id,
|
||||
stream_completeness=assessment.stream_completeness,
|
||||
restrictive_direction="none",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"pilot_lane": "qonto-assistant",
|
||||
"resource_scope": observation.resource_scope or "unknown",
|
||||
"originating_observation_id": observation.observation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -224,19 +352,40 @@ def _build_rationale(
|
|||
posture: PostureLevel,
|
||||
findings: tuple[str, ...],
|
||||
tolerated_findings: tuple[str, ...],
|
||||
completeness: StreamCompleteness,
|
||||
completeness_reason: str,
|
||||
) -> str:
|
||||
if posture is PostureLevel.HEALTHY:
|
||||
if tolerated_findings:
|
||||
return (
|
||||
content = (
|
||||
"Healthy posture with tolerated deviations only: "
|
||||
+ ", ".join(tolerated_findings)
|
||||
)
|
||||
return "Healthy posture: observation is compatible with declared intent."
|
||||
else:
|
||||
content = "Healthy posture: observation is compatible with declared intent."
|
||||
else:
|
||||
detail = ", ".join(findings) if findings else "no active findings"
|
||||
tolerated = (
|
||||
f" Tolerated deviations still present: {', '.join(tolerated_findings)}."
|
||||
if tolerated_findings
|
||||
else ""
|
||||
)
|
||||
content = f"{posture.value.title()} posture driven by {detail}.{tolerated}"
|
||||
|
||||
detail = ", ".join(findings) if findings else "no active findings"
|
||||
tolerated = (
|
||||
f" Tolerated deviations still present: {', '.join(tolerated_findings)}."
|
||||
if tolerated_findings
|
||||
else ""
|
||||
)
|
||||
return f"{posture.value.title()} posture driven by {detail}.{tolerated}"
|
||||
if completeness is StreamCompleteness.COMPLETE:
|
||||
stream_text = "Stream completeness is complete."
|
||||
elif completeness is StreamCompleteness.DEGRADED:
|
||||
stream_text = (
|
||||
"This judgment rests on a stream I cannot vouch for "
|
||||
f"({completeness_reason})."
|
||||
)
|
||||
else:
|
||||
stream_text = (
|
||||
"Stream completeness is unknown; confidence scores the record, "
|
||||
f"not the stream ({completeness_reason})."
|
||||
)
|
||||
return f"{content} {stream_text}"
|
||||
|
||||
|
||||
def is_stream_finding(finding: str) -> bool:
|
||||
return finding.startswith(STREAM_FINDING_PREFIX)
|
||||
|
|
|
|||
152
src/kings_guard/stream.py
Normal file
152
src/kings_guard/stream.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from kings_guard.cadence import EmissionCadence, parse_timestamp
|
||||
from kings_guard.contracts import (
|
||||
STREAM_FINDING_PREFIX,
|
||||
ImmuneObservation,
|
||||
ReconciliationView,
|
||||
StreamAssessment,
|
||||
StreamCompleteness,
|
||||
StreamHeartbeat,
|
||||
)
|
||||
|
||||
|
||||
def evaluate_stream(
|
||||
observations: Sequence[ImmuneObservation],
|
||||
cadence: EmissionCadence,
|
||||
*,
|
||||
now: str | datetime,
|
||||
watching_since: str | datetime | None = None,
|
||||
heartbeats: Sequence[StreamHeartbeat] = (),
|
||||
reconciliation: ReconciliationView | None = None,
|
||||
) -> StreamAssessment:
|
||||
"""Evaluate the stream, not its contents.
|
||||
|
||||
Findings are prefixed with `stream:` so they cannot be mistaken for
|
||||
content findings such as `credential_exfil_probe`.
|
||||
"""
|
||||
now_dt = _as_datetime(now)
|
||||
findings: list[str] = []
|
||||
reasons: list[str] = []
|
||||
observed_counts = _count_observations(observations)
|
||||
|
||||
watch_start = _as_datetime(watching_since) if watching_since is not None else None
|
||||
if watch_start is None:
|
||||
timestamps = [parse_timestamp(item.timestamp) for item in observations]
|
||||
timestamps.extend(parse_timestamp(item.timestamp) for item in heartbeats)
|
||||
watch_start = min(timestamps) if timestamps else now_dt
|
||||
|
||||
for rate in cadence.rates:
|
||||
window_start = now_dt - rate.window
|
||||
count = sum(
|
||||
1
|
||||
for item in observations
|
||||
if item.event_class == rate.event_class
|
||||
and parse_timestamp(item.timestamp) >= window_start
|
||||
)
|
||||
observed_counts[rate.event_class] = count
|
||||
watched_long_enough = (now_dt - watch_start) >= rate.window
|
||||
if watched_long_enough and count < rate.expected_min:
|
||||
findings.append(f"{STREAM_FINDING_PREFIX}cadence_unmet:{rate.event_class}")
|
||||
reasons.append(
|
||||
f"declared rate for {rate.event_class} is at least {rate.expected_min} "
|
||||
f"per {int(rate.window.total_seconds())}s; observed {count}"
|
||||
)
|
||||
|
||||
for spec in cadence.heartbeats:
|
||||
due = (now_dt - watch_start) >= spec.interval
|
||||
latest = _latest_heartbeat(heartbeats, spec.event_class)
|
||||
missing = False
|
||||
if latest is None:
|
||||
missing = due
|
||||
else:
|
||||
missing = (now_dt - parse_timestamp(latest.timestamp)) > spec.interval
|
||||
if missing:
|
||||
findings.append(f"{STREAM_FINDING_PREFIX}heartbeat_missing:{spec.covered_event_class}")
|
||||
reasons.append(
|
||||
f"no {spec.assertion} heartbeat for {spec.covered_event_class} "
|
||||
f"within {int(spec.interval.total_seconds())}s"
|
||||
)
|
||||
elif not due and latest is None:
|
||||
reasons.append(
|
||||
f"heartbeat for {spec.covered_event_class} is not yet due; "
|
||||
"the stream cannot be vouched for until a positive claim arrives"
|
||||
)
|
||||
|
||||
if cadence.reconciliations:
|
||||
if reconciliation is None:
|
||||
reasons.append(
|
||||
"reconciliation view was not supplied; divergence cannot be ruled out"
|
||||
)
|
||||
else:
|
||||
for spec in cadence.reconciliations:
|
||||
source_count = int(reconciliation.source_counts.get(spec.covered_event_class, 0))
|
||||
evidence_count = int(
|
||||
reconciliation.evidence_counts.get(spec.covered_event_class, 0)
|
||||
)
|
||||
if evidence_count < source_count:
|
||||
findings.append(
|
||||
f"{STREAM_FINDING_PREFIX}reconciliation_divergence:{spec.covered_event_class}"
|
||||
)
|
||||
reasons.append(
|
||||
f"{spec.covered_event_class} source transitions={source_count} "
|
||||
f"but evidence count={evidence_count}"
|
||||
)
|
||||
|
||||
completeness = _completeness(findings, reasons, cadence)
|
||||
reason = (
|
||||
"; ".join(reasons)
|
||||
if reasons
|
||||
else "declared cadence is met and no stream finding is open"
|
||||
)
|
||||
return StreamAssessment(
|
||||
completeness=completeness,
|
||||
reason=reason,
|
||||
findings=tuple(findings),
|
||||
observed_counts=observed_counts,
|
||||
window_end=now_dt.isoformat().replace("+00:00", "Z"),
|
||||
)
|
||||
|
||||
|
||||
def _completeness(
|
||||
findings: Sequence[str],
|
||||
reasons: Sequence[str],
|
||||
cadence: EmissionCadence,
|
||||
) -> StreamCompleteness:
|
||||
if findings:
|
||||
return StreamCompleteness.DEGRADED
|
||||
heartbeat_pending = any("not yet due" in item for item in reasons)
|
||||
reconciliation_unsupplied = any("was not supplied" in item for item in reasons)
|
||||
if heartbeat_pending or reconciliation_unsupplied:
|
||||
return StreamCompleteness.UNKNOWN
|
||||
if not cadence.heartbeats and not cadence.rates and not cadence.reconciliations:
|
||||
return StreamCompleteness.UNKNOWN
|
||||
return StreamCompleteness.COMPLETE
|
||||
|
||||
|
||||
def _count_observations(observations: Sequence[ImmuneObservation]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for item in observations:
|
||||
counts[item.event_class] = counts.get(item.event_class, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _latest_heartbeat(
|
||||
heartbeats: Sequence[StreamHeartbeat],
|
||||
event_class: str,
|
||||
) -> StreamHeartbeat | None:
|
||||
matching = [item for item in heartbeats if item.event_class == event_class]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=lambda item: parse_timestamp(item.timestamp))
|
||||
|
||||
|
||||
def _as_datetime(value: str | datetime) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
return parse_timestamp(value)
|
||||
Loading…
Add table
Add a link
Reference in a new issue