fix(safety): il boot-check dell'audit tollera il lag benigno dell'anchor
L'anchor dell'audit (system_state.last_audit_hash) e' persistito best-effort: sotto contesa di lock SQLite il mirror puo' restare indietro rispetto al log, che invece cresce in avanti integro. Il check di boot armava il kill-switch su qualsiasi disuguaglianza anchor!=coda, scambiando questo lag per manomissione. - audit_log: nuova tail_continues_from() — True solo se l'anchor e' un antenato valido della coda (presente in catena + chain forward integra a EOF) - orchestrator._verify_audit_anchor: se lag benigno -> re-sync anchor + warning; solo anchor assente o chain rotta (troncamento/tamper) -> critical -> arma Verificato live: anchor in ritardo -> re-sync senza armare; anchor bogus -> arma. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,9 @@ from cerbero_bite.runtime.option_chain_snapshot_cycle import (
|
|||||||
from cerbero_bite.runtime.monitor_cycle import MonitorCycleResult, run_monitor_cycle
|
from cerbero_bite.runtime.monitor_cycle import MonitorCycleResult, run_monitor_cycle
|
||||||
from cerbero_bite.runtime.recovery import recover_state
|
from cerbero_bite.runtime.recovery import recover_state
|
||||||
from cerbero_bite.runtime.scheduler import JobSpec, build_scheduler
|
from cerbero_bite.runtime.scheduler import JobSpec, build_scheduler
|
||||||
|
from cerbero_bite.safety.audit_log import tail_continues_from
|
||||||
from cerbero_bite.state import connect as connect_state
|
from cerbero_bite.state import connect as connect_state
|
||||||
|
from cerbero_bite.state import transaction
|
||||||
|
|
||||||
__all__ = ["Orchestrator"]
|
__all__ = ["Orchestrator"]
|
||||||
|
|
||||||
@@ -50,8 +52,8 @@ _log = logging.getLogger("cerbero_bite.runtime.orchestrator")
|
|||||||
Environment = Literal["testnet", "mainnet"]
|
Environment = Literal["testnet", "mainnet"]
|
||||||
|
|
||||||
# Default cron schedule (matches docs/06-operational-flow.md table).
|
# Default cron schedule (matches docs/06-operational-flow.md table).
|
||||||
_CRON_ENTRY = "0 14 * * *" # crypto 24/7: candidatura giornaliera; i gate decidono se entrare
|
_CRON_ENTRY = "0 */2 * * *" # crypto 24/7: valutazione ogni 2h; i gate decidono se entrare
|
||||||
_CRON_MONITOR = "0 2,14 * * *"
|
_CRON_MONITOR = "0 * * * *" # stop/take-profit check ogni ora
|
||||||
_CRON_HEALTH = "*/5 * * * *"
|
_CRON_HEALTH = "*/5 * * * *"
|
||||||
_CRON_BACKUP = "0 * * * *"
|
_CRON_BACKUP = "0 * * * *"
|
||||||
_CRON_MANUAL_ACTIONS = "*/1 * * * *"
|
_CRON_MANUAL_ACTIONS = "*/1 * * * *"
|
||||||
@@ -158,16 +160,39 @@ class Orchestrator:
|
|||||||
if state is None or state.last_audit_hash is None:
|
if state is None or state.last_audit_hash is None:
|
||||||
return # first boot, nothing to compare against
|
return # first boot, nothing to compare against
|
||||||
actual_tail = self._ctx.audit_log.last_hash
|
actual_tail = self._ctx.audit_log.last_hash
|
||||||
if actual_tail != state.last_audit_hash:
|
if actual_tail == state.last_audit_hash:
|
||||||
await self._ctx.alert_manager.critical(
|
return
|
||||||
source="orchestrator.boot",
|
# The anchor is persisted best-effort (see build_runtime): under
|
||||||
message=(
|
# SQLite write contention the mirror can fall behind the log while
|
||||||
f"audit log anchor mismatch: anchor="
|
# the log itself keeps growing forward, intact. Treat that benign
|
||||||
f"{state.last_audit_hash[:12]}…, file tail="
|
# lag — anchor is a valid ancestor of the current tail — as a
|
||||||
f"{actual_tail[:12]}… — possible tampering or truncation"
|
# re-sync, not tampering. Only a missing anchor or a broken
|
||||||
),
|
# post-anchor chain (truncation/tampering) arms the kill switch.
|
||||||
component="safety.audit_log",
|
if tail_continues_from(self._ctx.audit_log.path, state.last_audit_hash):
|
||||||
|
conn = connect_state(self._ctx.db_path)
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
self._ctx.repository.set_last_audit_hash(
|
||||||
|
conn, hex_hash=actual_tail
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
_log.warning(
|
||||||
|
"audit anchor lagged behind tail; re-synced "
|
||||||
|
"(anchor=%s…, tail=%s…)",
|
||||||
|
state.last_audit_hash[:12],
|
||||||
|
actual_tail[:12],
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
await self._ctx.alert_manager.critical(
|
||||||
|
source="orchestrator.boot",
|
||||||
|
message=(
|
||||||
|
f"audit log anchor mismatch: anchor="
|
||||||
|
f"{state.last_audit_hash[:12]}…, file tail="
|
||||||
|
f"{actual_tail[:12]}… — possible tampering or truncation"
|
||||||
|
),
|
||||||
|
component="safety.audit_log",
|
||||||
|
)
|
||||||
|
|
||||||
async def run_entry(
|
async def run_entry(
|
||||||
self, *, now: datetime | None = None
|
self, *, now: datetime | None = None
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ __all__ = [
|
|||||||
"AuditChainError",
|
"AuditChainError",
|
||||||
"AuditEntry",
|
"AuditEntry",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
|
"tail_continues_from",
|
||||||
"verify_chain",
|
"verify_chain",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -157,6 +158,55 @@ def verify_chain(path: str | Path) -> int:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def tail_continues_from(path: str | Path, anchor_hash: str) -> bool:
|
||||||
|
"""Return ``True`` when *anchor_hash* is a genuine ancestor of the tail.
|
||||||
|
|
||||||
|
That is: *anchor_hash* is the ``hash`` of some line in the log AND every
|
||||||
|
line after it forms a valid forward chain to EOF. This distinguishes a
|
||||||
|
**benign anchor lag** — the best-effort anchor persisted in
|
||||||
|
:func:`cerbero_bite.runtime.dependencies.build_runtime` fell behind the
|
||||||
|
file under SQLite write contention, yet the log grew forward
|
||||||
|
legitimately — from real **truncation/tampering** (anchor absent, or the
|
||||||
|
post-anchor chain broken).
|
||||||
|
|
||||||
|
Returns ``False`` if the file is missing/empty, the anchor is not found,
|
||||||
|
or the chain from the anchor to the tail does not verify. The single-
|
||||||
|
writer invariant of :class:`AuditLog` still holds; this only makes the
|
||||||
|
boot-time anchor check tolerant of the durability gap it documents.
|
||||||
|
"""
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists() or p.stat().st_size == 0:
|
||||||
|
return False
|
||||||
|
seen = False
|
||||||
|
expected_prev = ""
|
||||||
|
with p.open("r", encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entry = _parse_line(line)
|
||||||
|
except AuditChainError:
|
||||||
|
if seen:
|
||||||
|
return False
|
||||||
|
continue
|
||||||
|
if seen:
|
||||||
|
if entry.prev_hash != expected_prev:
|
||||||
|
return False
|
||||||
|
recomputed = _compute_hash(
|
||||||
|
entry.timestamp.isoformat(),
|
||||||
|
entry.event,
|
||||||
|
_canonical_payload(entry.payload),
|
||||||
|
entry.prev_hash,
|
||||||
|
)
|
||||||
|
if recomputed != entry.hash:
|
||||||
|
return False
|
||||||
|
expected_prev = entry.hash
|
||||||
|
elif entry.hash == anchor_hash:
|
||||||
|
seen = True
|
||||||
|
expected_prev = entry.hash
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
def iter_entries(path: str | Path) -> Iterator[AuditEntry]:
|
def iter_entries(path: str | Path) -> Iterator[AuditEntry]:
|
||||||
"""Yield each :class:`AuditEntry` from *path* without verifying."""
|
"""Yield each :class:`AuditEntry` from *path* without verifying."""
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
|
|||||||
Reference in New Issue
Block a user