Phase 2: persistence + safety controls
Aggiunge la persistenza SQLite, l'audit log a hash chain, il kill switch coordinato e i CLI di gestione documentati in docs/05-data-model.md e docs/07-risk-controls.md. 197 test pass, 1 skipped (sqlite3 CLI mancante), copertura totale 97%. State (`state/`): - 0001_init.sql con positions, instructions, decisions, dvol_history, manual_actions, system_state. - db.py: connect con WAL + foreign_keys + transaction ctx, runner forward-only basato su PRAGMA user_version. - models.py: record Pydantic, Decimal preservato come TEXT. - repository.py: CRUD typed con singola connessione passata, cache aware, posizioni concorrenti. Safety (`safety/`): - audit_log.py: AuditLog append-only con SHA-256 chain e fsync, verify_chain riconosce ogni manomissione (payload, prev_hash, hash, JSON, separatori). - kill_switch.py: arm/disarm transazionali, idempotenti, accoppiati all'audit chain. Config (`config/loader.py` + `strategy.yaml`): - Loader YAML con deep-merge di strategy.local.yaml. - Verifica config_hash SHA-256 (riga config_hash esclusa). - File golden strategy.yaml + esempio override. Scripts: - dead_man.sh: watchdog shell indipendente da Python. - backup.py: VACUUM INTO orario con retention 30 giorni. CLI: - audit verify (exit 2 su tampering). - kill-switch arm/disarm/status su SQLite reale. - state inspect con tabella posizioni aperte. - config hash, config validate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
"""Append-only hash-chained audit log (``docs/07-risk-controls.md``).
|
||||
|
||||
Every line is::
|
||||
|
||||
<iso-ts>|<event>|<json-payload>|prev_hash=<hex>|hash=<hex>
|
||||
|
||||
``hash`` is ``sha256("<iso-ts>|<event>|<json-payload>|<prev_hash>")`` so
|
||||
that the integrity of the file can be re-verified by walking the chain
|
||||
top-to-bottom. The first line uses ``prev_hash="0" * 64``.
|
||||
|
||||
The writer ``flush + os.fsync`` after every append; for the audit trail,
|
||||
durability beats throughput.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"GENESIS_HASH",
|
||||
"AuditChainError",
|
||||
"AuditEntry",
|
||||
"AuditLog",
|
||||
"verify_chain",
|
||||
]
|
||||
|
||||
|
||||
GENESIS_HASH = "0" * 64
|
||||
_SEP = "|"
|
||||
|
||||
|
||||
class AuditChainError(RuntimeError):
|
||||
"""Raised when the audit chain fails verification."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditEntry:
|
||||
"""Parsed audit-log line."""
|
||||
|
||||
timestamp: datetime
|
||||
event: str
|
||||
payload: dict[str, Any]
|
||||
prev_hash: str
|
||||
hash: str
|
||||
|
||||
|
||||
def _canonical_payload(payload: dict[str, Any]) -> str:
|
||||
"""Serialize the payload deterministically (sorted keys, no whitespace)."""
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
def _compute_hash(timestamp: str, event: str, payload_json: str, prev_hash: str) -> str:
|
||||
raw = f"{timestamp}{_SEP}{event}{_SEP}{payload_json}{_SEP}{prev_hash}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _format_line(
|
||||
timestamp: str, event: str, payload_json: str, prev_hash: str, line_hash: str
|
||||
) -> str:
|
||||
return (
|
||||
f"{timestamp}{_SEP}{event}{_SEP}{payload_json}{_SEP}"
|
||||
f"prev_hash={prev_hash}{_SEP}hash={line_hash}\n"
|
||||
)
|
||||
|
||||
|
||||
def _parse_line(line: str) -> AuditEntry:
|
||||
"""Parse a stored audit line back into :class:`AuditEntry`.
|
||||
|
||||
The payload may legitimately contain ``|`` characters inside JSON
|
||||
strings; we therefore split from the right for the two trailing
|
||||
fields and from the left for the timestamp + event + payload.
|
||||
"""
|
||||
if not line.endswith("\n"):
|
||||
line = line + "\n"
|
||||
body = line.rstrip("\n")
|
||||
|
||||
# Trailing parts.
|
||||
try:
|
||||
rest, hash_part = body.rsplit(_SEP, 1)
|
||||
except ValueError as exc:
|
||||
raise AuditChainError("missing hash= field") from exc
|
||||
if not hash_part.startswith("hash="):
|
||||
raise AuditChainError("missing hash= field")
|
||||
line_hash = hash_part[len("hash=") :]
|
||||
|
||||
try:
|
||||
rest, prev_part = rest.rsplit(_SEP, 1)
|
||||
except ValueError as exc:
|
||||
raise AuditChainError("missing prev_hash= field") from exc
|
||||
if not prev_part.startswith("prev_hash="):
|
||||
raise AuditChainError("missing prev_hash= field")
|
||||
prev_hash = prev_part[len("prev_hash=") :]
|
||||
|
||||
# Leading parts.
|
||||
parts = rest.split(_SEP, 2)
|
||||
if len(parts) != 3:
|
||||
raise AuditChainError("malformed leading section")
|
||||
ts_str, event, payload_json = parts
|
||||
|
||||
try:
|
||||
payload: dict[str, Any] = json.loads(payload_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AuditChainError("payload is not valid JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise AuditChainError("payload must be a JSON object")
|
||||
|
||||
return AuditEntry(
|
||||
timestamp=datetime.fromisoformat(ts_str),
|
||||
event=event,
|
||||
payload=payload,
|
||||
prev_hash=prev_hash,
|
||||
hash=line_hash,
|
||||
)
|
||||
|
||||
|
||||
def verify_chain(path: str | Path) -> int:
|
||||
"""Re-walk *path* and raise :class:`AuditChainError` on tampering.
|
||||
|
||||
Returns the number of lines verified (0 if the file does not exist
|
||||
or is empty).
|
||||
"""
|
||||
p = Path(path)
|
||||
if not p.exists() or p.stat().st_size == 0:
|
||||
return 0
|
||||
expected_prev = GENESIS_HASH
|
||||
count = 0
|
||||
with p.open("r", encoding="utf-8") as fh:
|
||||
for lineno, line in enumerate(fh, start=1):
|
||||
if not line.strip():
|
||||
continue
|
||||
entry = _parse_line(line)
|
||||
if entry.prev_hash != expected_prev:
|
||||
raise AuditChainError(
|
||||
f"line {lineno}: prev_hash mismatch "
|
||||
f"(expected {expected_prev}, got {entry.prev_hash})"
|
||||
)
|
||||
recomputed = _compute_hash(
|
||||
entry.timestamp.isoformat(),
|
||||
entry.event,
|
||||
_canonical_payload(entry.payload),
|
||||
entry.prev_hash,
|
||||
)
|
||||
if recomputed != entry.hash:
|
||||
raise AuditChainError(
|
||||
f"line {lineno}: hash mismatch (expected {recomputed}, "
|
||||
f"got {entry.hash})"
|
||||
)
|
||||
expected_prev = entry.hash
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def iter_entries(path: str | Path) -> Iterator[AuditEntry]:
|
||||
"""Yield each :class:`AuditEntry` from *path* without verifying."""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return
|
||||
with p.open("r", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if line.strip():
|
||||
yield _parse_line(line)
|
||||
|
||||
|
||||
class AuditLog:
|
||||
"""Writer for the hash-chained audit log.
|
||||
|
||||
A single instance per process is enough; concurrent writers are not
|
||||
supported by design (the engine is the only writer). ``append`` is
|
||||
fsync'd before returning.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self._path = Path(path)
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._last_hash: str = self._tail_hash() or GENESIS_HASH
|
||||
|
||||
@property
|
||||
def path(self) -> Path: # pragma: no cover — accessor used by callers only
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def last_hash(self) -> str:
|
||||
return self._last_hash
|
||||
|
||||
def _tail_hash(self) -> str | None:
|
||||
if not self._path.exists() or self._path.stat().st_size == 0:
|
||||
return None
|
||||
# Walk from EOF to find the last non-empty line. The chunked
|
||||
# back-seek covers files larger than 4 KiB; the loop-exhausted
|
||||
# branch is reached only when a partial / no-newline file is
|
||||
# encountered (defensive — :func:`append` always writes "\n").
|
||||
with self._path.open("rb") as fh:
|
||||
fh.seek(0, os.SEEK_END)
|
||||
size = fh.tell()
|
||||
buf = b""
|
||||
offset = size
|
||||
chunk = 4096
|
||||
while offset > 0: # pragma: no branch — terminates via break or offset==0
|
||||
read = min(chunk, offset)
|
||||
offset -= read
|
||||
fh.seek(offset)
|
||||
buf = fh.read(read) + buf
|
||||
if b"\n" in buf:
|
||||
break
|
||||
text = buf.decode("utf-8", errors="strict")
|
||||
for line in reversed(text.splitlines()): # pragma: no branch
|
||||
if line.strip():
|
||||
entry = _parse_line(line)
|
||||
return entry.hash
|
||||
return None # pragma: no cover — only hit when file is all blank lines
|
||||
|
||||
def append(
|
||||
self,
|
||||
*,
|
||||
event: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> AuditEntry:
|
||||
"""Append one event line and return the resulting entry."""
|
||||
ts = (now or datetime.now(UTC)).astimezone(UTC)
|
||||
ts_iso = ts.isoformat()
|
||||
payload_json = _canonical_payload(payload or {})
|
||||
prev_hash = self._last_hash
|
||||
line_hash = _compute_hash(ts_iso, event, payload_json, prev_hash)
|
||||
|
||||
line = _format_line(ts_iso, event, payload_json, prev_hash, line_hash)
|
||||
with self._path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(line)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
|
||||
self._last_hash = line_hash
|
||||
return AuditEntry(
|
||||
timestamp=ts,
|
||||
event=event,
|
||||
payload=dict(payload or {}),
|
||||
prev_hash=prev_hash,
|
||||
hash=line_hash,
|
||||
)
|
||||
Reference in New Issue
Block a user