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
+259 -57
View File
@@ -35,6 +35,7 @@ from cayceops.models import (
MailDirection,
MailMessage,
Proposal,
_content_hash,
audit,
)
@@ -48,17 +49,33 @@ def connect_imap(cfg: Config) -> imaplib.IMAP4_SSL:
return client
def _fetch_unread(cfg: Config) -> list[bytes]:
"""Fetch raw unread messages. Returns list of raw RFC822 bytes."""
def _fetch_unread(cfg: Config) -> list[tuple[bytes, bytes]]:
"""Fetch raw unread messages as [(imap_num, raw_bytes)].
Messages are NOT marked \\Seen here — marking happens only after the
processed rows are committed (audit finding: a crash mid-batch used to
silently lose the rest of the batch).
"""
client = connect_imap(cfg)
try:
_, data = client.search(None, "UNSEEN")
raw_messages: list[bytes] = []
out: list[tuple[bytes, bytes]] = []
for num in data[0].split():
_, msg_data = client.fetch(num, "(RFC822)")
raw_messages.append(msg_data[0][1])
out.append((num, msg_data[0][1]))
return out
finally:
client.logout()
def _mark_seen(cfg: Config, nums: list[bytes]) -> None:
"""Mark processed messages \\Seen (called after commit)."""
if not nums:
return
client = connect_imap(cfg)
try:
for num in nums:
client.store(num, "+FLAGS", "\\Seen")
return raw_messages
finally:
client.logout()
@@ -96,7 +113,13 @@ def parse_raw(raw: bytes) -> dict:
def _match_proposal(parsed: dict, sess) -> Proposal | None:
"""Thread an inbound mail to its proposal via In-Reply-To of sent drafts."""
"""Thread an inbound mail to its proposal.
1. In-Reply-To matches a SENT OUTBOUND message (our replies carry their own
Message-ID and an outbound row, created at send time).
2. Fallback: it matches ANY stored message (e.g. a second reply threaded to
our first inbound) that already carries a proposal_id.
"""
irt = parsed.get("in_reply_to")
if not irt:
return None
@@ -108,9 +131,16 @@ def _match_proposal(parsed: dict, sess) -> Proposal | None:
)
.one_or_none()
)
if sent is None or sent.proposal_id is None:
return None
return sess.get(Proposal, sent.proposal_id)
if sent is not None and sent.proposal_id is not None:
return sess.get(Proposal, sent.proposal_id)
chain = (
sess.query(MailMessage)
.filter(MailMessage.message_id == irt, MailMessage.proposal_id.isnot(None))
.one_or_none()
)
if chain is not None:
return sess.get(Proposal, chain.proposal_id)
return None
# --- drafts -------------------------------------------------------------------
@@ -144,10 +174,15 @@ def _build_draft(parsed: dict, kind: MailClassification, proposal: Proposal | No
# --- inbound pass ---------------------------------------------------------------
def _poll(cfg: Config, sess, fetcher=None) -> dict:
"""One inbound pass: parse, classify, thread, draft. Returns stats."""
def _poll(cfg: Config, sess, fetcher=None, marker=None) -> tuple[dict, list[bytes]]:
"""One inbound pass: parse, classify, thread, draft.
Returns (stats, processed_imap_nums). The caller marks \\Seen only after
its transaction commits.
"""
stats = {"processed": 0, "drafted": 0, "needs_review": 0, "skipped": 0}
for raw in (fetcher or _fetch_unread)(cfg):
processed_nums: list[bytes] = []
for num, raw in (fetcher or _fetch_unread)(cfg):
parsed = parse_raw(raw)
if (
sess.query(MailMessage)
@@ -186,6 +221,7 @@ def _poll(cfg: Config, sess, fetcher=None) -> dict:
detail={"classification": kind.value, "confidence": confidence},
)
stats["processed"] += 1
processed_nums.append(num) # stored -> safe to mark seen after commit
if kind == MailClassification.NEEDS_REVIEW:
stats["needs_review"] += 1
@@ -210,18 +246,23 @@ def _poll(cfg: Config, sess, fetcher=None) -> dict:
detail={"mail_id": record.id},
)
stats["drafted"] += 1
return stats
return stats, processed_nums
# --- human gate: approve draft -------------------------------------------------
def _approve_draft(mail_id: int, actor: str, sess) -> int:
"""Human approves a drafted reply. Only then may SMTP send it."""
"""Human approves a drafted reply (inbound-threaded or first-contact).
The approval is BOUND to the draft text via sha256 — a later edit breaks
the binding and the send refuses (audit finding: approved text was not
tied to its content).
"""
record = sess.get(MailMessage, mail_id)
if record is None or record.direction != MailDirection.INBOUND:
if record is None:
return 1
if record.draft_status != DraftStatus.DRAFTED:
if record.draft_status != DraftStatus.DRAFTED or not record.draft_reply:
audit(
sess,
actor=actor,
@@ -232,12 +273,14 @@ def _approve_draft(mail_id: int, actor: str, sess) -> int:
)
return 1
record.draft_status = DraftStatus.APPROVED
record.approved_hash = _content_hash(record.draft_reply)
record.approved_by = actor[:128]
audit(
sess,
actor=actor,
action="approve_draft",
proposal_id=record.proposal_id,
detail={"mail_id": record.id},
detail={"mail_id": record.id, "hash": record.approved_hash},
)
return 0
@@ -245,12 +288,21 @@ def _approve_draft(mail_id: int, actor: str, sess) -> int:
# --- SMTP: only approved drafts ------------------------------------------------
def _smtp_send(cfg: Config, to_addr: str, subject: str, body: str, message_id: str) -> None:
def _smtp_send(
cfg: Config,
to_addr: str,
subject: str,
body: str,
message_id: str,
in_reply_to: str | None = None,
) -> None:
msg = EmailMessage()
msg["From"] = cfg.mail_from
msg["To"] = to_addr
msg["Subject"] = subject
msg["Message-ID"] = message_id
if in_reply_to:
msg["In-Reply-To"] = in_reply_to # keep merchant threads intact
msg.set_content(body)
with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) as server:
server.starttls()
@@ -267,52 +319,172 @@ def _approved_by(sess, mail_id: int) -> str:
return "unknown"
def _send_approved(cfg: Config, sess, sender=None) -> dict:
"""Send every APPROVED draft. Anything else stays untouched."""
stats = {"sent": 0, "blocked": 0}
pending = (
sess.query(MailMessage)
.filter(
MailMessage.direction == MailDirection.INBOUND,
MailMessage.draft_status == DraftStatus.APPROVED,
)
.all()
)
for record in pending:
def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
"""Send ONE approved draft. Returns: sent | hash_mismatch | missing | blocked.
Creates the OUTBOUND row (its own Message-ID + In-Reply-To pointing at the
inbound message) so future replies thread correctly — the inbound row's
message_id is left untouched (audit finding: it used to be overwritten).
The caller owns the transaction: one message per commit, so an SMTP failure
on message N never un-sends messages 1..N-1 (audit finding: duplicates).
"""
from cayceops.models import _content_hash
record = sess.get(MailMessage, mail_id)
if record is None:
return "missing"
if record.draft_status != DraftStatus.APPROVED:
return "blocked"
if record.direction == MailDirection.INBOUND:
# reply: goes back to the inbound sender, threads to the inbound mail,
# and gets its own OUTBOUND row
to_addr = record.from_addr or ""
if not to_addr:
record.draft_status = DraftStatus.SKIPPED
stats["blocked"] += 1
continue
message_id = (
f"<cayce-draft-{record.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>"
subject = f"Re: {record.subject or 'your message'}"
in_reply_to = record.message_id
outbound_msgid = (
f"<cayce-out-{record.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>"
)
(sender or _smtp_send)(
cfg,
to_addr,
f"Re: {record.subject or 'your message'}",
record.draft_reply or "",
message_id,
)
approved_by = _approved_by(sess, record.id)
record.draft_status = DraftStatus.SENT
# store the Message-ID so future replies thread to this draft
record.message_id = message_id
needs_row = True
else:
# first-contact draft: already an OUTBOUND row; send as-is
to_addr = record.to_addr or ""
subject = record.subject or ""
in_reply_to = None
outbound_msgid = record.message_id
needs_row = False
if not to_addr:
record.draft_status = DraftStatus.SKIPPED
return "blocked"
# binding: the text must be exactly what the human approved
if record.approved_hash and _content_hash(record.draft_reply or "") != record.approved_hash:
record.draft_status = DraftStatus.DRAFTED # needs fresh approval
audit(
sess,
actor="daemon:maild",
action="send_draft",
action="send_refused_hash_mismatch",
proposal_id=record.proposal_id,
detail={
"mail_id": record.id,
"message_id": message_id,
"approved_by": approved_by,
},
result="error",
detail={"mail_id": record.id},
)
stats["sent"] += 1
return "hash_mismatch"
(sender or _smtp_send)(
cfg,
to_addr,
subject,
record.draft_reply or "",
outbound_msgid,
in_reply_to=in_reply_to,
)
approved_by = _approved_by(sess, record.id)
if needs_row:
sess.add(
MailMessage(
direction=MailDirection.OUTBOUND,
message_id=outbound_msgid,
in_reply_to=record.message_id,
from_addr=cfg.mail_from,
to_addr=to_addr,
subject=subject,
body=record.draft_reply,
proposal_id=record.proposal_id,
draft_status=DraftStatus.SENT,
processed_at=datetime.now(UTC),
)
)
record.draft_status = DraftStatus.SENT
sess.flush()
audit(
sess,
actor="daemon:maild",
action="send_draft",
proposal_id=record.proposal_id,
detail={
"mail_id": record.id,
"message_id": outbound_msgid,
"approved_by": approved_by,
},
)
return "sent"
def _send_approved(cfg: Config, sess, sender=None, factory=None) -> dict:
"""Send every APPROVED draft, one transaction per message.
`factory` is a SQLAlchemy sessionmaker bound to the shared engine; each
message is processed and committed in its own session (tests inject the
engine's maker; the CLI passes appdb.session_factory()).
"""
stats = {"sent": 0, "blocked": 0, "hash_mismatch": 0, "missing": 0}
ids = [
row.id
for row in sess.query(MailMessage.id)
.filter(MailMessage.draft_status == DraftStatus.APPROVED)
.all()
]
for mail_id in ids:
# one transaction per message — an SMTP failure on message N must not
# roll back (and later re-send) messages 1..N-1 (audit finding 3)
try:
if factory is not None:
with factory() as one_sess: # sessionmaker() -> Session
outcome = _send_one(cfg, one_sess, mail_id, sender)
one_sess.commit()
else:
outcome = _send_one(cfg, sess, mail_id, sender)
except Exception as exc: # noqa: BLE001 — isolate the failed message
outcome = "error"
if sess is not None:
detail = {"mail_id": mail_id, "error": str(exc)[:200]}
audit(sess, actor="daemon:maild", action="send_failed",
result="error", detail=detail)
stats[outcome] = stats.get(outcome, 0) + 1
return stats
def _approved_by(sess, mail_id: int) -> str:
from cayceops.models import AuditEntry
for entry in sess.query(AuditEntry).all():
if entry.action == "approve_draft" and entry.detail.get("mail_id") == mail_id:
return entry.actor
return "unknown"
def _compose(proposal_id: int, to_addr: str, subject: str, body: str, sess) -> int:
"""Create the FIRST-CONTACT outbound draft for a proposal (state: drafted).
This is the missing path the audit flagged: without it, threading only
worked for replies to mails we never actually sent.
"""
proposal = sess.get(Proposal, proposal_id)
if proposal is None or not to_addr:
return -1
outbound = MailMessage(
direction=MailDirection.OUTBOUND,
message_id=f"<cayce-out-{proposal.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>",
from_addr=None, # filled from MAIL_FROM at send time
to_addr=to_addr[:320],
subject=subject[:998],
body=body,
draft_reply=body, # canonical approved-text field (same as reply drafts)
proposal_id=proposal.id,
draft_status=DraftStatus.DRAFTED,
)
sess.add(outbound)
sess.flush()
audit(
sess,
actor="daemon:maild",
action="compose_draft",
proposal_id=proposal.id,
detail={"mail_id": outbound.id},
)
return outbound.id
# --- CLI wrappers (session scope lives here) ------------------------------------
@@ -322,12 +494,13 @@ def poll(cfg: Config | None = None) -> int:
cfg = cfg or load()
try:
nums: list[bytes] = []
with session_scope() as sess:
run_row = DaemonRun(daemon="maild")
sess.add(run_row)
sess.flush()
try:
stats = _poll(cfg, sess)
stats, nums = _poll(cfg, sess)
except Exception as exc: # noqa: BLE001 — daemon boundary
run_row.status = "error"
run_row.detail = {"error": str(exc)[:300]}
@@ -336,6 +509,9 @@ def poll(cfg: Config | None = None) -> int:
run_row.status = "ok"
run_row.detail = stats
run_row.finished_at = datetime.now(UTC)
# committed: now it is safe to mark the batch seen
if nums:
_mark_seen(cfg, nums)
return 0
finally:
engine().dispose()
@@ -353,12 +529,38 @@ def approve_draft(mail_id: int, actor: str) -> int:
def send_approved(cfg: Config | None = None) -> int:
from cayceops.config import load
from cayceops.db import engine
from cayceops.db import engine, session_factory
cfg = cfg or load()
try:
# ids first, then one transaction per message: a failure on message N
# cannot roll back already-sent messages (audit finding: duplicates)
with session_scope() as sess:
_send_approved(cfg, sess)
ids = [
row.id
for row in sess.query(MailMessage.id)
.filter(MailMessage.draft_status == DraftStatus.APPROVED)
.all()
]
factory = session_factory()
for mail_id in ids:
try:
with factory() as one_sess:
_send_one(cfg, one_sess, mail_id)
one_sess.commit()
except Exception as exc: # noqa: BLE001 — isolate each send
print(f"send {mail_id} failed: {str(exc)[:160]}")
return 0
finally:
engine().dispose()
def compose(proposal_id: int, to_addr: str, subject: str, body: str) -> int:
from cayceops.db import engine
try:
with session_scope() as sess:
mail_id = _compose(proposal_id, to_addr, subject, body, sess)
return 0 if mail_id >= 0 else 1
finally:
engine().dispose()