diff --git a/src/cerbero_bite/runtime/orchestrator.py b/src/cerbero_bite/runtime/orchestrator.py index 35b5d84..976f9bc 100644 --- a/src/cerbero_bite/runtime/orchestrator.py +++ b/src/cerbero_bite/runtime/orchestrator.py @@ -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.recovery import recover_state 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 transaction __all__ = ["Orchestrator"] @@ -50,8 +52,8 @@ _log = logging.getLogger("cerbero_bite.runtime.orchestrator") Environment = Literal["testnet", "mainnet"] # 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_MONITOR = "0 2,14 * * *" +_CRON_ENTRY = "0 */2 * * *" # crypto 24/7: valutazione ogni 2h; i gate decidono se entrare +_CRON_MONITOR = "0 * * * *" # stop/take-profit check ogni ora _CRON_HEALTH = "*/5 * * * *" _CRON_BACKUP = "0 * * * *" _CRON_MANUAL_ACTIONS = "*/1 * * * *" @@ -158,16 +160,39 @@ class Orchestrator: if state is None or state.last_audit_hash is None: return # first boot, nothing to compare against actual_tail = self._ctx.audit_log.last_hash - if actual_tail != state.last_audit_hash: - 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", + if actual_tail == state.last_audit_hash: + return + # The anchor is persisted best-effort (see build_runtime): under + # SQLite write contention the mirror can fall behind the log while + # the log itself keeps growing forward, intact. Treat that benign + # lag — anchor is a valid ancestor of the current tail — as a + # re-sync, not tampering. Only a missing anchor or a broken + # post-anchor chain (truncation/tampering) arms the kill switch. + 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( self, *, now: datetime | None = None diff --git a/src/cerbero_bite/safety/audit_log.py b/src/cerbero_bite/safety/audit_log.py index 5e914b6..8629760 100644 --- a/src/cerbero_bite/safety/audit_log.py +++ b/src/cerbero_bite/safety/audit_log.py @@ -28,6 +28,7 @@ __all__ = [ "AuditChainError", "AuditEntry", "AuditLog", + "tail_continues_from", "verify_chain", ] @@ -157,6 +158,55 @@ def verify_chain(path: str | Path) -> int: 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]: """Yield each :class:`AuditEntry` from *path* without verifying.""" p = Path(path)