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
+48 -16
View File
@@ -16,7 +16,13 @@ from datetime import UTC, datetime, timedelta
from cayceops.components.publisher import images, nostr
from cayceops.config import Config
from cayceops.db import session_scope
from cayceops.models import ContentItem, ContentStatus, DaemonRun, audit
from cayceops.models import (
ContentItem,
ContentStatus,
DaemonRun,
_content_hash,
audit,
)
# rotation: one theme per slot; channel rotation nostr -> facebook -> instagram
THEMES = [
@@ -120,11 +126,12 @@ def _approve(item_id: int, actor: str, sess) -> int:
)
return 1
item.status = ContentStatus.APPROVED
item.approved_hash = _content_hash(item.caption) # binding: caption as approved
audit(
sess,
actor=actor,
action="approve_content",
detail={"item": item.id, "channel": item.channel},
detail={"item": item.id, "channel": item.channel, "hash": item.approved_hash},
)
return 0
@@ -174,7 +181,7 @@ def _publish_nostr(item: ContentItem, cfg: Config, sess, sender=None) -> str:
def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
"""Publish approved items that are due. Non-nostr channels are skipped
unless their adapters are explicitly enabled (default: skipped, audited)."""
stats = {"published": 0, "skipped": 0, "failed": 0}
stats = {"published": 0, "skipped": 0, "failed": 0, "tampered": 0}
due = (
sess.query(ContentItem)
.filter(ContentItem.status == ContentStatus.APPROVED)
@@ -184,31 +191,56 @@ def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
for item in due:
if item.scheduled_for and item.scheduled_for > now:
continue # not due yet
# binding: caption must be exactly what was approved (audit finding)
if item.approved_hash and _content_hash(item.caption) != item.approved_hash:
audit(
sess,
actor="daemon:publisher",
action="publish_refused_hash_mismatch",
result="error",
detail={"item": item.id},
)
stats["tampered"] += 1
continue
try:
if item.channel == "nostr":
item.post_ref = _publish_nostr(item, cfg, sess, sender=nostr_sender)
else:
# disabled channel is TERMINAL (audit finding: this used to
# write a skip audit row every 5 minutes, forever)
item.status = ContentStatus.SKIPPED
item.error = f"{item.channel} adapter disabled by default"
audit(
sess,
actor="daemon:publisher",
action="publish_skipped",
detail={
"item": item.id,
"channel": item.channel,
"reason": f"{item.channel} adapter disabled by default",
},
action="publish_skipped_terminal",
detail={"item": item.id, "channel": item.channel},
)
stats["skipped"] += 1
continue
except Exception as exc: # noqa: BLE001 — publish boundary: audit and continue
item.attempts += 1
item.error = str(exc)[:300]
audit(
sess,
actor="daemon:publisher",
action="publish_failed",
result="error",
detail={"item": item.id, "error": item.error},
)
if item.attempts >= cfg.max_attempts:
# attempt budget exhausted -> terminal (no infinite retries)
item.status = ContentStatus.SKIPPED
audit(
sess,
actor="daemon:publisher",
action="publish_abandoned",
result="error",
detail={"item": item.id, "attempts": item.attempts},
)
else:
audit(
sess,
actor="daemon:publisher",
action="publish_failed",
result="error",
detail={"item": item.id, "error": item.error, "attempt": item.attempts},
)
stats["failed"] += 1
continue
item.status = ContentStatus.PUBLISHED