fix: address external audit — criticals 1-5 + mediums

Critical 1 (approval forgery): audit_log is now hash-chained
(prev_hash/entry_hash, verify_audit_chain); executor approve REQUIRES a
BIP-340 signature over sha256(cayce-ops:approve:<id>) when
CAYCE_OPS_OPERATOR_PUBKEY is set; deploy/roles.sql ships least-privilege
per-daemon DB roles.

Critical 2 (threading dead): sends create real OUTBOUND rows (own
Message-ID, In-Reply-To -> inbound); inbound message_id never overwritten;
maild compose adds the first-contact path; _match_proposal falls back to
inbound chains.

Critical 3 (duplicate sends): per-message transactions in send path —
SMTP failure on message N no longer resurrects 1..N-1; failures audited
(send_failed) and the row stays queued.

Critical 4 (classifier): negated approvals (cannot accept / not approved /
not accepting) classify REJECTION at 0.95 before the bare-keyword rule;
LLM prompt braces escaped (.format no longer KeyError into silent fallback).

Critical 5 (systemd): every daemon has a run subcommand
(maild run = poll+send, publisher run = publish-due, monitor run = build);
deploy README documents the separate daily publisher plan timer.

Mediums: approved_hash binds drafts/captions to the approved text (edited
text refuses to send/publish until re-approved); risk_score renamed
match_score (migration 0002) and auto-approve orders HIGHEST relevance
first; compose binds postgres to 127.0.0.1; IMAP marks \\Seen only after
commit (fetch returns (num, raw), mark post-commit); disabled channels are
terminal (ContentStatus.SKIPPED) and publish failures burn a bounded
attempt budget before abandonment; make hooks installs the pre-push gate.

tests: 12 new — 56 total, all green; ruff 0 findings; gitleaks clean.
This commit is contained in:
2026-10-19 23:06:29 +00:00
parent 2da5f3ff24
commit 90e6cfcc19
22 changed files with 1012 additions and 135 deletions
+81 -6
View File
@@ -5,13 +5,22 @@ Status machines
proposal: proposed -> approved -> running -> done | failed
proposed -> rejected
mail: (inbound) classified: approval|rejection|question|other|needs_review
(outbound draft) none -> drafted -> approved -> sent | skipped
content: draft -> approved -> published
(any) draft: none -> drafted -> approved -> sent | skipped
content: draft -> approved -> published | skipped (terminal)
Audit chain
-----------
audit_log is hash-chained: each entry stores sha256(prev_hash | fields).
``cayceops.models.verify_audit_chain`` re-walks it; the monitor surfaces breaks.
Not tamper-proof (DB writes can rewrite the chain) but tamper-EVIDENT against
selective edits. See AUDIT.md / audit response 2026-10-20.
"""
from __future__ import annotations
import enum
import hashlib
import json
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text
@@ -22,6 +31,11 @@ def utcnow() -> datetime:
return datetime.now(UTC)
def _content_hash(text: str) -> str:
"""Binding hash for human-approved text (drafts, captions)."""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
class Base(DeclarativeBase):
pass
@@ -54,7 +68,8 @@ class Proposal(Base):
title: Mapped[str] = mapped_column(String(512))
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
detail: Mapped[dict] = mapped_column(JSON, default=dict)
risk_score: Mapped[float] = mapped_column(Float, default=0.0)
# keyword-match relevance 0..1 — NOT risk. auto-approve takes highest first.
match_score: Mapped[float] = mapped_column(Float, default=0.0)
status: Mapped[ProposalStatus] = mapped_column(
Enum(ProposalStatus), default=ProposalStatus.PROPOSED, index=True
)
@@ -68,7 +83,7 @@ class Proposal(Base):
)
# --- audit -------------------------------------------------------------------
# --- audit (hash-chained) ----------------------------------------------------
class AuditEntry(Base):
@@ -76,13 +91,16 @@ class AuditEntry(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
actor: Mapped[str] = mapped_column(String(128)) # human username | auto:policy | daemon:<name>
actor: Mapped[str] = mapped_column(String(128)) # human | auto:policy | daemon:<name>
proposal_id: Mapped[int | None] = mapped_column(
ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True
)
action: Mapped[str] = mapped_column(String(64)) # approve|reject|execute|classify|...
detail: Mapped[dict] = mapped_column(JSON, default=dict)
result: Mapped[str] = mapped_column(String(32), default="ok") # ok | error
# tamper-evident chain
prev_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
entry_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
def audit(
@@ -93,18 +111,68 @@ def audit(
detail: dict | None = None,
result: str = "ok",
) -> AuditEntry:
"""Record one audited action. Every component writes through this helper."""
"""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.
"""
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
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(
[
prev_hash or "GENESIS",
str(ts_int),
actor,
action,
str(proposal_id),
detail_json,
result,
]
)
entry = AuditEntry(
ts=ts,
actor=actor,
action=action,
proposal_id=proposal_id,
detail=detail or {},
result=result,
prev_hash=prev_hash,
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"
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
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,
str(int(ts.timestamp())),
entry.actor,
entry.action,
str(entry.proposal_id),
detail_json,
entry.result,
]
)
expected = hashlib.sha256(payload.encode("utf-8")).hexdigest()
if entry.prev_hash != prev_hash or entry.entry_hash != expected:
return False, entry.id
prev_hash = entry.entry_hash
return True, None
# --- mail --------------------------------------------------------------------
@@ -151,6 +219,9 @@ class MailMessage(Base):
Enum(DraftStatus), default=DraftStatus.NONE
)
draft_reply: Mapped[str | None] = mapped_column(Text, nullable=True)
# 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)
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -161,6 +232,7 @@ class ContentStatus(enum.StrEnum):
DRAFT = "draft"
APPROVED = "approved"
PUBLISHED = "published"
SKIPPED = "skipped" # terminal: disabled channel or attempt budget exhausted
class ContentItem(Base):
@@ -179,6 +251,9 @@ class ContentItem(Base):
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
post_ref: Mapped[str | None] = mapped_column(String(512), nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)