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:
2026-04-27 13:35:35 +02:00
parent fbb7753cc6
commit 263470786d
25 changed files with 3669 additions and 14 deletions
+246
View File
@@ -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,
)