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
+23 -2
View File
@@ -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}"""