audit-1 (critical 1): mandatory operator signatures bound to content+deployment

- signing module: canonical content hashes + approval_message
  sha256(cayce-ops:<type>:<deployment_id>:<id>:<content_hash>)
- approve/approve-draft/publisher-approve REQUIRE signature; without
  CAYCE_OPS_OPERATOR_PUBKEY+DEPLOYMENT_ID the daemons refuse to
  execute/send/publish (no degraded mode)
- run-time re-verification at execute/send/publish: unsigned rows (raw SQL
  approval) or edited content revert to pre-approval state, audited as error
- FULL signatures stored on rows (operator_signature, migration 0003) and
  in audit detail — never truncated
- signature failures audited from CLI wrappers too (1f)
- AUTONOMOUS_MODE approval path removed (D13): no non-human approvals

tests: SQL-approved row refused at execute/send/publish; to_addr change
blocks send; foreign deployment/content signatures refused; full-signature
persistence. 65 tests green.
This commit is contained in:
2026-10-20 02:20:09 +00:00
parent 61d5cfd67d
commit 40453aadb4
13 changed files with 1209 additions and 273 deletions
+86 -14
View File
@@ -21,9 +21,10 @@ from __future__ import annotations
import enum
import hashlib
import json
import threading
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -77,6 +78,8 @@ class Proposal(Base):
dedupe_key: Mapped[str | None] = mapped_column(String(256), unique=True, nullable=True)
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# full operator approval signature (128 hex chars) — never truncated
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
@@ -103,6 +106,11 @@ class AuditEntry(Base):
entry_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# reentrant: several audit() calls may share one transaction
_AUDIT_LOCK = threading.RLock()
_AUDIT_LOCK_PG_KEY = 7471186757 # pg_advisory_xact_lock key for cross-process
def audit(
sess,
actor: str,
@@ -113,13 +121,66 @@ def audit(
) -> AuditEntry:
"""Record one audited action, chained to the previous entry.
Chain: entry_hash = sha256(prev_hash | ts | actor | action | proposal_id | detail | result).
Every component writes through this helper.
Chain: entry_hash = sha256(prev_hash | ts_epoch | actor | action |
proposal_id | detail | result). Writes are SERIALIZED (audit round 2,
point 4): pg_advisory_xact_lock on Postgres, in-process lock on SQLite —
concurrent writers can no longer fork the chain.
Legacy rows (entry_hash IS NULL, pre-0002) are ignored when picking
prev_hash: the chain starts GENESIS at the first hashed row.
"""
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
bind = sess.get_bind()
if bind.dialect.name == "postgresql":
# xact-scoped: PG releases the lock at commit/rollback automatically
sess.execute(
text("SELECT pg_advisory_xact_lock(:k)"), {"k": _AUDIT_LOCK_PG_KEY}
)
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
entry = _build_entry(prev_hash, actor, action, proposal_id, detail, result)
sess.add(entry)
return entry
# SQLite path: hold the in-process lock until the session commits or
# rolls back, so concurrent writers cannot read the same prev_hash
# (round-2 point 4 — verified by test_concurrent_writers_keep_chain_verifiable)
_AUDIT_LOCK.acquire()
released = False
def _release(*_args):
nonlocal released
if not released:
released = True
try:
_AUDIT_LOCK.release()
except RuntimeError: # pragma: no cover
pass
from sqlalchemy import event
event.listen(sess, "after_commit", _release)
event.listen(sess, "after_rollback", _release)
event.listen(sess, "after_soft_rollback", _release)
try:
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
entry = _build_entry(prev_hash, actor, action, proposal_id, detail, result)
sess.add(entry)
return entry
except Exception:
_release()
raise
def _build_entry(
prev_hash: str,
actor: str,
action: str,
proposal_id: int | None,
detail: dict | None,
result: str,
) -> AuditEntry:
ts = utcnow()
# epoch int: identical after a sqlite/pg round-trip (tz-aware isoformat is not)
ts_int = int(ts.timestamp())
detail_json = json.dumps(detail or {}, sort_keys=True, ensure_ascii=False)
payload = "|".join(
@@ -133,31 +194,40 @@ def audit(
result,
]
)
entry = AuditEntry(
return AuditEntry(
ts=ts,
actor=actor,
action=action,
proposal_id=proposal_id,
detail=detail or {},
result=result,
prev_hash=prev_hash,
prev_hash=prev_hash or "GENESIS",
entry_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
)
sess.add(entry)
return entry
def verify_audit_chain(sess) -> tuple[bool, int | None]:
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None)."""
prev_hash = "GENESIS"
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None).
Legacy rows (entry_hash IS NULL, written before migration 0002) are
skipped and documented: the chain begins GENESIS at the first hashed row,
so a migrated database verifies clean without any tampering.
"""
prev_hash: str | None = None # None = still in legacy prefix
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
if entry.entry_hash is None:
continue # legacy row: outside the chain by documented genesis
if prev_hash is None:
expected_prev = "GENESIS" # first hashed row anchors the chain
else:
expected_prev = prev_hash
detail_json = json.dumps(entry.detail or {}, sort_keys=True, ensure_ascii=False)
ts = entry.ts
if ts.tzinfo is None:
ts = ts.replace(tzinfo=UTC)
payload = "|".join(
[
prev_hash,
expected_prev,
str(int(ts.timestamp())),
entry.actor,
entry.action,
@@ -167,7 +237,7 @@ def verify_audit_chain(sess) -> tuple[bool, int | None]:
]
)
expected = hashlib.sha256(payload.encode("utf-8")).hexdigest()
if entry.prev_hash != prev_hash or entry.entry_hash != expected:
if entry.prev_hash != expected_prev or entry.entry_hash != expected:
return False, entry.id
prev_hash = entry.entry_hash
return True, None
@@ -222,6 +292,7 @@ class MailMessage(Base):
# binding: hash of the draft text the human approved + who approved it
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -254,6 +325,7 @@ class ContentItem(Base):
attempts: Mapped[int] = mapped_column(Integer, default=0)
# binding: hash of the caption the human approved
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)