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:
@@ -13,12 +13,30 @@ import re
|
||||
from cayceops.config import Config
|
||||
from cayceops.models import MailClassification
|
||||
|
||||
# NEGATED approval forms are rejections and must be checked FIRST:
|
||||
# "We cannot accept your application", "not accepting new partners",
|
||||
# "Your application was not approved" used to match the bare approval
|
||||
# keywords (accept/approv) and read as approvals — audit finding 4.
|
||||
_NEGATED_APPROVAL = (
|
||||
MailClassification.REJECTION,
|
||||
re.compile(
|
||||
r"\b(cannot|can.?t|will not|won.?t|do not|don.?t|does not|doesn.?t)\s+"
|
||||
r"(be\s+able\s+to\s+)?(accept|approve|proceed|offer|welcome)"
|
||||
r"|\bnot\s+(been\s+)?approved\b"
|
||||
r"|\bnot\s+accepting\b"
|
||||
r"|\bunfortunately\b",
|
||||
re.I,
|
||||
),
|
||||
0.95,
|
||||
)
|
||||
|
||||
_RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
|
||||
_NEGATED_APPROVAL,
|
||||
# (class, pattern, confidence when matched)
|
||||
(
|
||||
MailClassification.REJECTION,
|
||||
# noqa: E501 (kept on one line: the pattern IS the reviewed artifact)
|
||||
re.compile(r"\b(declin|reject|not able to|unable to|unfortunately|we regret|won.t be able)\w*", re.I), # noqa: E501
|
||||
re.compile(r"\b(declin|reject|not able to|unable to|we regret|won.t be able)\w*", re.I), # noqa: E501
|
||||
0.9,
|
||||
),
|
||||
(
|
||||
@@ -33,9 +51,12 @@ _RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
|
||||
),
|
||||
]
|
||||
|
||||
# braces are DOUBLED so .format() does not die on the literal JSON sample
|
||||
# (the old prompt raised KeyError on every call and the generic except
|
||||
# silently degraded to rules — audit finding 4)
|
||||
_LLM_PROMPT = """Classify this merchant email into exactly one of:
|
||||
approval, rejection, question, other.
|
||||
Reply with strict JSON: {"classification": "...", "confidence": 0.0-1.0}
|
||||
Reply with strict JSON: {{"classification": "...", "confidence": 0.0-1.0}}
|
||||
|
||||
Subject and body:
|
||||
{body}"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user