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:
+44
-3
@@ -71,10 +71,16 @@ def executor_run() -> None:
|
||||
|
||||
@executor_app.command("approve")
|
||||
def executor_approve(
|
||||
proposal_id: int, actor: str = typer.Option(..., help="Human approver id")
|
||||
proposal_id: int,
|
||||
actor: str = typer.Option(..., help="Human approver id"),
|
||||
signature: str = typer.Option(
|
||||
None, help="BIP-340 sig (required when operator pubkey is set)"
|
||||
),
|
||||
) -> None:
|
||||
"""Approve a proposal (human action, audited)."""
|
||||
raise typer.Exit(_load_component("executor").approve(proposal_id, actor))
|
||||
raise typer.Exit(
|
||||
_load_component("executor").approve(proposal_id, actor, signature=signature)
|
||||
)
|
||||
|
||||
|
||||
@executor_app.command("reject")
|
||||
@@ -102,10 +108,33 @@ def maild_approve(msg_id: int, actor: str = typer.Option(...)) -> None:
|
||||
|
||||
@maild_app.command("send-approved")
|
||||
def maild_send() -> None:
|
||||
"""Send all approved drafts via SMTP."""
|
||||
"""Send all approved drafts via SMTP (per-message commit)."""
|
||||
raise typer.Exit(_load_component("maild").send_approved())
|
||||
|
||||
|
||||
@maild_app.command("compose")
|
||||
def maild_compose(
|
||||
proposal_id: int,
|
||||
to_addr: str = typer.Option(...),
|
||||
subject: str = typer.Option(...),
|
||||
body: str = typer.Option(...),
|
||||
) -> None:
|
||||
"""Create the FIRST-CONTACT outbound draft for a proposal (drafted)."""
|
||||
raise typer.Exit(
|
||||
_load_component("maild").compose(proposal_id, to_addr, subject, body)
|
||||
)
|
||||
|
||||
|
||||
@maild_app.command("run")
|
||||
def maild_run() -> None:
|
||||
"""One daemon cycle: poll inbound, then send approved drafts."""
|
||||
comp = _load_component("maild")
|
||||
code = comp.poll()
|
||||
if code == 0:
|
||||
code = comp.send_approved()
|
||||
raise typer.Exit(code)
|
||||
|
||||
|
||||
# --- publisher ---------------------------------------------------------------
|
||||
publisher_app = typer.Typer(name="publisher", help="Themed content calendar")
|
||||
app.add_typer(publisher_app)
|
||||
@@ -135,6 +164,12 @@ def publisher_publish() -> None:
|
||||
raise typer.Exit(_load_component("publisher").publish_due())
|
||||
|
||||
|
||||
@publisher_app.command("run")
|
||||
def publisher_run() -> None:
|
||||
"""One daemon cycle: publish due approved items (plan is cron'd separately)."""
|
||||
raise typer.Exit(_load_component("publisher").publish_due())
|
||||
|
||||
|
||||
# --- monitor -----------------------------------------------------------------
|
||||
monitor_app = typer.Typer(name="monitor", help="Static status page")
|
||||
app.add_typer(monitor_app)
|
||||
@@ -146,6 +181,12 @@ def monitor_build() -> None:
|
||||
raise typer.Exit(_load_component("monitor").build())
|
||||
|
||||
|
||||
@monitor_app.command("run")
|
||||
def monitor_run() -> None:
|
||||
"""One daemon cycle: build the status page."""
|
||||
raise typer.Exit(_load_component("monitor").build())
|
||||
|
||||
|
||||
def main() -> None: # pragma: no cover
|
||||
app()
|
||||
|
||||
|
||||
@@ -29,12 +29,59 @@ from cayceops.models import AuditEntry, DaemonRun, Proposal, ProposalStatus, aud
|
||||
Actor = str
|
||||
|
||||
|
||||
def _load_cfg():
|
||||
from cayceops.config import load
|
||||
|
||||
return load()
|
||||
|
||||
|
||||
# --- human transitions -------------------------------------------------------
|
||||
|
||||
# message signed by the operator key when CAYCE_OPS_OPERATOR_PUBKEY is set
|
||||
def approval_message(proposal_id: int) -> bytes:
|
||||
import hashlib
|
||||
|
||||
def approve(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
|
||||
"""Human approves a proposal. Audited with the operator's identity."""
|
||||
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess)
|
||||
return hashlib.sha256(f"cayce-ops:approve:{proposal_id}".encode()).digest()
|
||||
|
||||
|
||||
def approve(
|
||||
proposal_id: int,
|
||||
actor: Actor,
|
||||
cfg: Config | None = None,
|
||||
sess=None,
|
||||
signature: str | None = None,
|
||||
) -> int:
|
||||
"""Human approves a proposal. Audited with the operator's identity.
|
||||
|
||||
When CAYCE_OPS_OPERATOR_PUBKEY is configured, `signature` (hex, BIP-340
|
||||
over approval_message(id)) is REQUIRED and verified against that key —
|
||||
'actor' then is a label, the key is the proof (audit finding 1).
|
||||
"""
|
||||
from cayceops.components.publisher.nostr import verify_schnorr
|
||||
|
||||
cfg = cfg or _load_cfg()
|
||||
if cfg.operator_pubkey:
|
||||
ok = bool(signature) and verify_schnorr(
|
||||
signature, approval_message(proposal_id), cfg.operator_pubkey
|
||||
)
|
||||
if not ok and sess is not None:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve",
|
||||
proposal_id=proposal_id,
|
||||
result="error",
|
||||
detail={
|
||||
"reason": (
|
||||
"operator pubkey configured: signature required"
|
||||
if not signature
|
||||
else "invalid approval signature"
|
||||
)
|
||||
},
|
||||
)
|
||||
if not ok:
|
||||
return 2
|
||||
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess, signature)
|
||||
|
||||
|
||||
def reject(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
|
||||
@@ -48,6 +95,7 @@ def _decide(
|
||||
decision: ProposalStatus,
|
||||
cfg: Config | None,
|
||||
sess,
|
||||
signature: str | None = None,
|
||||
) -> int:
|
||||
owned = False
|
||||
if sess is None:
|
||||
@@ -70,11 +118,14 @@ def _decide(
|
||||
)
|
||||
return 1
|
||||
proposal.status = decision
|
||||
detail: dict = {}
|
||||
if decision == ProposalStatus.APPROVED:
|
||||
proposal.approved_by = actor
|
||||
proposal.approved_at = datetime.now(UTC)
|
||||
if signature:
|
||||
detail["signature"] = signature[:32] + "..."
|
||||
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
|
||||
audit(sess, actor=actor, action=verb, proposal_id=proposal_id)
|
||||
audit(sess, actor=actor, action=verb, proposal_id=proposal_id, detail=detail)
|
||||
return 0
|
||||
finally:
|
||||
if owned:
|
||||
@@ -102,13 +153,15 @@ def _auto_approve(cfg: Config, sess) -> int:
|
||||
approved = 0
|
||||
if budget == 0:
|
||||
return 0
|
||||
# most relevant first (match_score = keyword relevance, NOT risk — the
|
||||
# audit's naming finding; ascending order approved noise first)
|
||||
candidates = (
|
||||
sess.query(Proposal)
|
||||
.filter(
|
||||
Proposal.status == ProposalStatus.PROPOSED,
|
||||
Proposal.kind.in_(cfg.autonomous_kinds),
|
||||
)
|
||||
.order_by(Proposal.risk_score.asc(), Proposal.id.asc())
|
||||
.order_by(Proposal.match_score.desc(), Proposal.id.asc())
|
||||
.limit(budget)
|
||||
.all()
|
||||
)
|
||||
@@ -124,7 +177,7 @@ def _auto_approve(cfg: Config, sess) -> int:
|
||||
detail={
|
||||
"reason": "autonomous_mode policy",
|
||||
"kind": proposal.kind,
|
||||
"risk_score": proposal.risk_score,
|
||||
"match_score": proposal.match_score,
|
||||
"daily_cap": cfg.autonomous_daily_cap,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -148,6 +148,16 @@ def verify_event(event: dict[str, Any]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def verify_schnorr(sig_hex: str, message: bytes, pubkey_hex: str) -> bool:
|
||||
"""Verify a BIP-340 signature over an arbitrary 32-byte message."""
|
||||
try:
|
||||
return _schnorr_verify(
|
||||
bytes.fromhex(sig_hex), message, bytes.fromhex(pubkey_hex)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def relay_publish(relay_url: str, event: dict[str, Any], timeout: float = 10.0) -> str:
|
||||
"""Publish one event to one relay; return the relay's OK message."""
|
||||
from websockets.sync.client import connect
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -165,7 +165,7 @@ def scan(cfg: Config, sess, fetch=_fetch) -> dict:
|
||||
"summary": desc,
|
||||
"scanned_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
risk_score=score,
|
||||
match_score=score,
|
||||
status=ProposalStatus.PROPOSED,
|
||||
dedupe_key=dedupe,
|
||||
)
|
||||
|
||||
@@ -84,6 +84,11 @@ class Config:
|
||||
enable_http_actions: bool = field(
|
||||
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_HTTP_ACTIONS", False)
|
||||
)
|
||||
# when set (64-char hex x-only pubkey), human approvals MUST carry a valid
|
||||
# BIP-340 signature over "cayce-ops:approve:<id>" made by the operator key.
|
||||
operator_pubkey: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_OPERATOR_PUBKEY", "")
|
||||
)
|
||||
|
||||
# --- mail ---
|
||||
imap_host: str = field(
|
||||
|
||||
+81
-6
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user