Merge PR #9: audit fixes — criticals 1-5 + mediums (56 tests, gate green)
ci / lint-test-leaks (push) Canceled after 0s

This commit is contained in:
2026-10-19 23:06:30 +00:00
22 changed files with 1012 additions and 135 deletions
+3
View File
@@ -21,6 +21,9 @@ CAYCE_OPS_AUTONOMOUS_KINDS=
CAYCE_OPS_AUTONOMOUS_DAILY_CAP=0 CAYCE_OPS_AUTONOMOUS_DAILY_CAP=0
CAYCE_OPS_MAX_ATTEMPTS=2 CAYCE_OPS_MAX_ATTEMPTS=2
CAYCE_OPS_ENABLE_HTTP_ACTIONS=false CAYCE_OPS_ENABLE_HTTP_ACTIONS=false
# when set (64-char hex x-only pubkey), executor approve REQUIRES a BIP-340
# signature over sha256("cayce-ops:approve:<id>") made by the operator key
CAYCE_OPS_OPERATOR_PUBKEY=
# --- mail (IMAP read / SMTP send-approved-drafts-only) --- # --- mail (IMAP read / SMTP send-approved-drafts-only) ---
CAYCE_OPS_IMAP_HOST=imap.example.org CAYCE_OPS_IMAP_HOST=imap.example.org
+5 -1
View File
@@ -1,4 +1,4 @@
.PHONY: check lint test migrate-check leaks sync .PHONY: check lint test migrate-check leaks sync hooks
sync: ## install locked deps (creates .venv) sync: ## install locked deps (creates .venv)
uv sync --frozen uv sync --frozen
@@ -17,5 +17,9 @@ migrate-check: ## smoke the alembic chain on a scratch sqlite db
leaks: ## gitleaks over the full history leaks: ## gitleaks over the full history
gitleaks detect --source . --log-opts="--all" -v gitleaks detect --source . --log-opts="--all" -v
hooks: ## install the pre-push gate as a git hook (part of setup)
git config core.hooksPath scripts/githooks
@echo "pre-push gate installed (scripts/githooks/pre-push)"
check: lint test migrate-check leaks ## the pre-push gate — must be green before every push check: lint test migrate-check leaks ## the pre-push gate — must be green before every push
@echo "check: OK" @echo "check: OK"
+68
View File
@@ -0,0 +1,68 @@
"""audit hardening migration — minimal standalone mirror of 0002.
Revision ID: 0002
Revises: 0001
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
OLD_CONTENT_STATUSES = ("DRAFT", "APPROVED", "PUBLISHED")
NEW_CONTENT_STATUSES = ("DRAFT", "APPROVED", "PUBLISHED", "SKIPPED")
def upgrade() -> None:
with op.batch_alter_table("audit_log") as batch:
batch.add_column(sa.Column("prev_hash", sa.String(length=64), nullable=True))
batch.add_column(sa.Column("entry_hash", sa.String(length=64), nullable=True))
batch.create_index("ix_audit_log_entry_hash", ["entry_hash"])
with op.batch_alter_table("proposals") as batch:
batch.alter_column("risk_score", new_column_name="match_score")
with op.batch_alter_table("mail_messages") as batch:
batch.add_column(sa.Column("approved_hash", sa.String(length=64), nullable=True))
batch.add_column(sa.Column("approved_by", sa.String(length=128), nullable=True))
if op.get_bind().dialect.name == "postgresql":
op.execute("ALTER TYPE contentstatus ADD VALUE IF NOT EXISTS 'SKIPPED'")
with op.batch_alter_table("content_items") as batch:
batch.alter_column(
"status",
existing_type=sa.Enum(*OLD_CONTENT_STATUSES, name="contentstatus"),
type_=sa.Enum(*NEW_CONTENT_STATUSES, name="contentstatus"),
existing_nullable=False,
)
batch.add_column(sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"))
batch.add_column(sa.Column("approved_hash", sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("content_items") as batch:
batch.drop_column("approved_hash")
batch.drop_column("attempts")
batch.alter_column(
"status",
existing_type=sa.Enum(*NEW_CONTENT_STATUSES, name="contentstatus"),
type_=sa.Enum(*OLD_CONTENT_STATUSES, name="contentstatus"),
existing_nullable=False,
)
with op.batch_alter_table("mail_messages") as batch:
batch.drop_column("approved_by")
batch.drop_column("approved_hash")
with op.batch_alter_table("proposals") as batch:
batch.alter_column("match_score", new_column_name="risk_score")
with op.batch_alter_table("audit_log") as batch:
batch.drop_index("ix_audit_log_entry_hash")
batch.drop_column("entry_hash")
batch.drop_column("prev_hash")
+44 -3
View File
@@ -71,10 +71,16 @@ def executor_run() -> None:
@executor_app.command("approve") @executor_app.command("approve")
def executor_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: ) -> None:
"""Approve a proposal (human action, audited).""" """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") @executor_app.command("reject")
@@ -102,10 +108,33 @@ def maild_approve(msg_id: int, actor: str = typer.Option(...)) -> None:
@maild_app.command("send-approved") @maild_app.command("send-approved")
def maild_send() -> None: 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()) 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 ---------------------------------------------------------------
publisher_app = typer.Typer(name="publisher", help="Themed content calendar") publisher_app = typer.Typer(name="publisher", help="Themed content calendar")
app.add_typer(publisher_app) app.add_typer(publisher_app)
@@ -135,6 +164,12 @@ def publisher_publish() -> None:
raise typer.Exit(_load_component("publisher").publish_due()) 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 -----------------------------------------------------------------
monitor_app = typer.Typer(name="monitor", help="Static status page") monitor_app = typer.Typer(name="monitor", help="Static status page")
app.add_typer(monitor_app) app.add_typer(monitor_app)
@@ -146,6 +181,12 @@ def monitor_build() -> None:
raise typer.Exit(_load_component("monitor").build()) 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 def main() -> None: # pragma: no cover
app() app()
+59 -6
View File
@@ -29,12 +29,59 @@ from cayceops.models import AuditEntry, DaemonRun, Proposal, ProposalStatus, aud
Actor = str Actor = str
def _load_cfg():
from cayceops.config import load
return load()
# --- human transitions ------------------------------------------------------- # --- 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: return hashlib.sha256(f"cayce-ops:approve:{proposal_id}".encode()).digest()
"""Human approves a proposal. Audited with the operator's identity."""
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess)
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: def reject(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
@@ -48,6 +95,7 @@ def _decide(
decision: ProposalStatus, decision: ProposalStatus,
cfg: Config | None, cfg: Config | None,
sess, sess,
signature: str | None = None,
) -> int: ) -> int:
owned = False owned = False
if sess is None: if sess is None:
@@ -70,11 +118,14 @@ def _decide(
) )
return 1 return 1
proposal.status = decision proposal.status = decision
detail: dict = {}
if decision == ProposalStatus.APPROVED: if decision == ProposalStatus.APPROVED:
proposal.approved_by = actor proposal.approved_by = actor
proposal.approved_at = datetime.now(UTC) proposal.approved_at = datetime.now(UTC)
if signature:
detail["signature"] = signature[:32] + "..."
verb = "approve" if decision == ProposalStatus.APPROVED else "reject" 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 return 0
finally: finally:
if owned: if owned:
@@ -102,13 +153,15 @@ def _auto_approve(cfg: Config, sess) -> int:
approved = 0 approved = 0
if budget == 0: if budget == 0:
return 0 return 0
# most relevant first (match_score = keyword relevance, NOT risk — the
# audit's naming finding; ascending order approved noise first)
candidates = ( candidates = (
sess.query(Proposal) sess.query(Proposal)
.filter( .filter(
Proposal.status == ProposalStatus.PROPOSED, Proposal.status == ProposalStatus.PROPOSED,
Proposal.kind.in_(cfg.autonomous_kinds), 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) .limit(budget)
.all() .all()
) )
@@ -124,7 +177,7 @@ def _auto_approve(cfg: Config, sess) -> int:
detail={ detail={
"reason": "autonomous_mode policy", "reason": "autonomous_mode policy",
"kind": proposal.kind, "kind": proposal.kind,
"risk_score": proposal.risk_score, "match_score": proposal.match_score,
"daily_cap": cfg.autonomous_daily_cap, "daily_cap": cfg.autonomous_daily_cap,
}, },
) )
+23 -2
View File
@@ -13,12 +13,30 @@ import re
from cayceops.config import Config from cayceops.config import Config
from cayceops.models import MailClassification 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]] = [ _RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
_NEGATED_APPROVAL,
# (class, pattern, confidence when matched) # (class, pattern, confidence when matched)
( (
MailClassification.REJECTION, MailClassification.REJECTION,
# noqa: E501 (kept on one line: the pattern IS the reviewed artifact) # 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, 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: _LLM_PROMPT = """Classify this merchant email into exactly one of:
approval, rejection, question, other. 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: Subject and body:
{body}""" {body}"""
+259 -57
View File
@@ -35,6 +35,7 @@ from cayceops.models import (
MailDirection, MailDirection,
MailMessage, MailMessage,
Proposal, Proposal,
_content_hash,
audit, audit,
) )
@@ -48,17 +49,33 @@ def connect_imap(cfg: Config) -> imaplib.IMAP4_SSL:
return client return client
def _fetch_unread(cfg: Config) -> list[bytes]: def _fetch_unread(cfg: Config) -> list[tuple[bytes, bytes]]:
"""Fetch raw unread messages. Returns list of raw RFC822 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) client = connect_imap(cfg)
try: try:
_, data = client.search(None, "UNSEEN") _, data = client.search(None, "UNSEEN")
raw_messages: list[bytes] = [] out: list[tuple[bytes, bytes]] = []
for num in data[0].split(): for num in data[0].split():
_, msg_data = client.fetch(num, "(RFC822)") _, 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") client.store(num, "+FLAGS", "\\Seen")
return raw_messages
finally: finally:
client.logout() client.logout()
@@ -96,7 +113,13 @@ def parse_raw(raw: bytes) -> dict:
def _match_proposal(parsed: dict, sess) -> Proposal | None: 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") irt = parsed.get("in_reply_to")
if not irt: if not irt:
return None return None
@@ -108,9 +131,16 @@ def _match_proposal(parsed: dict, sess) -> Proposal | None:
) )
.one_or_none() .one_or_none()
) )
if sent is None or sent.proposal_id is None: if sent is not None and sent.proposal_id is not None:
return None return sess.get(Proposal, sent.proposal_id)
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 ------------------------------------------------------------------- # --- drafts -------------------------------------------------------------------
@@ -144,10 +174,15 @@ def _build_draft(parsed: dict, kind: MailClassification, proposal: Proposal | No
# --- inbound pass --------------------------------------------------------------- # --- inbound pass ---------------------------------------------------------------
def _poll(cfg: Config, sess, fetcher=None) -> dict: def _poll(cfg: Config, sess, fetcher=None, marker=None) -> tuple[dict, list[bytes]]:
"""One inbound pass: parse, classify, thread, draft. Returns stats.""" """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} 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) parsed = parse_raw(raw)
if ( if (
sess.query(MailMessage) sess.query(MailMessage)
@@ -186,6 +221,7 @@ def _poll(cfg: Config, sess, fetcher=None) -> dict:
detail={"classification": kind.value, "confidence": confidence}, detail={"classification": kind.value, "confidence": confidence},
) )
stats["processed"] += 1 stats["processed"] += 1
processed_nums.append(num) # stored -> safe to mark seen after commit
if kind == MailClassification.NEEDS_REVIEW: if kind == MailClassification.NEEDS_REVIEW:
stats["needs_review"] += 1 stats["needs_review"] += 1
@@ -210,18 +246,23 @@ def _poll(cfg: Config, sess, fetcher=None) -> dict:
detail={"mail_id": record.id}, detail={"mail_id": record.id},
) )
stats["drafted"] += 1 stats["drafted"] += 1
return stats return stats, processed_nums
# --- human gate: approve draft ------------------------------------------------- # --- human gate: approve draft -------------------------------------------------
def _approve_draft(mail_id: int, actor: str, sess) -> int: 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) record = sess.get(MailMessage, mail_id)
if record is None or record.direction != MailDirection.INBOUND: if record is None:
return 1 return 1
if record.draft_status != DraftStatus.DRAFTED: if record.draft_status != DraftStatus.DRAFTED or not record.draft_reply:
audit( audit(
sess, sess,
actor=actor, actor=actor,
@@ -232,12 +273,14 @@ def _approve_draft(mail_id: int, actor: str, sess) -> int:
) )
return 1 return 1
record.draft_status = DraftStatus.APPROVED record.draft_status = DraftStatus.APPROVED
record.approved_hash = _content_hash(record.draft_reply)
record.approved_by = actor[:128]
audit( audit(
sess, sess,
actor=actor, actor=actor,
action="approve_draft", action="approve_draft",
proposal_id=record.proposal_id, proposal_id=record.proposal_id,
detail={"mail_id": record.id}, detail={"mail_id": record.id, "hash": record.approved_hash},
) )
return 0 return 0
@@ -245,12 +288,21 @@ def _approve_draft(mail_id: int, actor: str, sess) -> int:
# --- SMTP: only approved drafts ------------------------------------------------ # --- 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 = EmailMessage()
msg["From"] = cfg.mail_from msg["From"] = cfg.mail_from
msg["To"] = to_addr msg["To"] = to_addr
msg["Subject"] = subject msg["Subject"] = subject
msg["Message-ID"] = message_id msg["Message-ID"] = message_id
if in_reply_to:
msg["In-Reply-To"] = in_reply_to # keep merchant threads intact
msg.set_content(body) msg.set_content(body)
with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) as server: with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) as server:
server.starttls() server.starttls()
@@ -267,52 +319,172 @@ def _approved_by(sess, mail_id: int) -> str:
return "unknown" return "unknown"
def _send_approved(cfg: Config, sess, sender=None) -> dict: def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
"""Send every APPROVED draft. Anything else stays untouched.""" """Send ONE approved draft. Returns: sent | hash_mismatch | missing | blocked.
stats = {"sent": 0, "blocked": 0}
pending = ( Creates the OUTBOUND row (its own Message-ID + In-Reply-To pointing at the
sess.query(MailMessage) inbound message) so future replies thread correctly — the inbound row's
.filter( message_id is left untouched (audit finding: it used to be overwritten).
MailMessage.direction == MailDirection.INBOUND, The caller owns the transaction: one message per commit, so an SMTP failure
MailMessage.draft_status == DraftStatus.APPROVED, on message N never un-sends messages 1..N-1 (audit finding: duplicates).
) """
.all() from cayceops.models import _content_hash
)
for record in pending: 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 "" to_addr = record.from_addr or ""
if not to_addr: subject = f"Re: {record.subject or 'your message'}"
record.draft_status = DraftStatus.SKIPPED in_reply_to = record.message_id
stats["blocked"] += 1 outbound_msgid = (
continue f"<cayce-out-{record.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>"
message_id = (
f"<cayce-draft-{record.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>"
) )
(sender or _smtp_send)( needs_row = True
cfg, else:
to_addr, # first-contact draft: already an OUTBOUND row; send as-is
f"Re: {record.subject or 'your message'}", to_addr = record.to_addr or ""
record.draft_reply or "", subject = record.subject or ""
message_id, in_reply_to = None
) outbound_msgid = record.message_id
approved_by = _approved_by(sess, record.id) needs_row = False
record.draft_status = DraftStatus.SENT if not to_addr:
# store the Message-ID so future replies thread to this draft record.draft_status = DraftStatus.SKIPPED
record.message_id = message_id 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( audit(
sess, sess,
actor="daemon:maild", actor="daemon:maild",
action="send_draft", action="send_refused_hash_mismatch",
proposal_id=record.proposal_id, proposal_id=record.proposal_id,
detail={ result="error",
"mail_id": record.id, detail={"mail_id": record.id},
"message_id": message_id,
"approved_by": approved_by,
},
) )
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 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) ------------------------------------ # --- CLI wrappers (session scope lives here) ------------------------------------
@@ -322,12 +494,13 @@ def poll(cfg: Config | None = None) -> int:
cfg = cfg or load() cfg = cfg or load()
try: try:
nums: list[bytes] = []
with session_scope() as sess: with session_scope() as sess:
run_row = DaemonRun(daemon="maild") run_row = DaemonRun(daemon="maild")
sess.add(run_row) sess.add(run_row)
sess.flush() sess.flush()
try: try:
stats = _poll(cfg, sess) stats, nums = _poll(cfg, sess)
except Exception as exc: # noqa: BLE001 — daemon boundary except Exception as exc: # noqa: BLE001 — daemon boundary
run_row.status = "error" run_row.status = "error"
run_row.detail = {"error": str(exc)[:300]} run_row.detail = {"error": str(exc)[:300]}
@@ -336,6 +509,9 @@ def poll(cfg: Config | None = None) -> int:
run_row.status = "ok" run_row.status = "ok"
run_row.detail = stats run_row.detail = stats
run_row.finished_at = datetime.now(UTC) 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 return 0
finally: finally:
engine().dispose() engine().dispose()
@@ -353,12 +529,38 @@ def approve_draft(mail_id: int, actor: str) -> int:
def send_approved(cfg: Config | None = None) -> int: def send_approved(cfg: Config | None = None) -> int:
from cayceops.config import load from cayceops.config import load
from cayceops.db import engine from cayceops.db import engine, session_factory
cfg = cfg or load() cfg = cfg or load()
try: 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: 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 return 0
finally: finally:
engine().dispose() 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()
+10
View File
@@ -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: 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.""" """Publish one event to one relay; return the relay's OK message."""
from websockets.sync.client import connect from websockets.sync.client import connect
+48 -16
View File
@@ -16,7 +16,13 @@ from datetime import UTC, datetime, timedelta
from cayceops.components.publisher import images, nostr from cayceops.components.publisher import images, nostr
from cayceops.config import Config from cayceops.config import Config
from cayceops.db import session_scope 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 # rotation: one theme per slot; channel rotation nostr -> facebook -> instagram
THEMES = [ THEMES = [
@@ -120,11 +126,12 @@ def _approve(item_id: int, actor: str, sess) -> int:
) )
return 1 return 1
item.status = ContentStatus.APPROVED item.status = ContentStatus.APPROVED
item.approved_hash = _content_hash(item.caption) # binding: caption as approved
audit( audit(
sess, sess,
actor=actor, actor=actor,
action="approve_content", action="approve_content",
detail={"item": item.id, "channel": item.channel}, detail={"item": item.id, "channel": item.channel, "hash": item.approved_hash},
) )
return 0 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: def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
"""Publish approved items that are due. Non-nostr channels are skipped """Publish approved items that are due. Non-nostr channels are skipped
unless their adapters are explicitly enabled (default: skipped, audited).""" 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 = ( due = (
sess.query(ContentItem) sess.query(ContentItem)
.filter(ContentItem.status == ContentStatus.APPROVED) .filter(ContentItem.status == ContentStatus.APPROVED)
@@ -184,31 +191,56 @@ def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
for item in due: for item in due:
if item.scheduled_for and item.scheduled_for > now: if item.scheduled_for and item.scheduled_for > now:
continue # not due yet 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: try:
if item.channel == "nostr": if item.channel == "nostr":
item.post_ref = _publish_nostr(item, cfg, sess, sender=nostr_sender) item.post_ref = _publish_nostr(item, cfg, sess, sender=nostr_sender)
else: 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( audit(
sess, sess,
actor="daemon:publisher", actor="daemon:publisher",
action="publish_skipped", action="publish_skipped_terminal",
detail={ detail={"item": item.id, "channel": item.channel},
"item": item.id,
"channel": item.channel,
"reason": f"{item.channel} adapter disabled by default",
},
) )
stats["skipped"] += 1 stats["skipped"] += 1
continue continue
except Exception as exc: # noqa: BLE001 — publish boundary: audit and continue except Exception as exc: # noqa: BLE001 — publish boundary: audit and continue
item.attempts += 1
item.error = str(exc)[:300] item.error = str(exc)[:300]
audit( if item.attempts >= cfg.max_attempts:
sess, # attempt budget exhausted -> terminal (no infinite retries)
actor="daemon:publisher", item.status = ContentStatus.SKIPPED
action="publish_failed", audit(
result="error", sess,
detail={"item": item.id, "error": item.error}, 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 stats["failed"] += 1
continue continue
item.status = ContentStatus.PUBLISHED item.status = ContentStatus.PUBLISHED
+1 -1
View File
@@ -165,7 +165,7 @@ def scan(cfg: Config, sess, fetch=_fetch) -> dict:
"summary": desc, "summary": desc,
"scanned_at": datetime.now(UTC).isoformat(), "scanned_at": datetime.now(UTC).isoformat(),
}, },
risk_score=score, match_score=score,
status=ProposalStatus.PROPOSED, status=ProposalStatus.PROPOSED,
dedupe_key=dedupe, dedupe_key=dedupe,
) )
+5
View File
@@ -84,6 +84,11 @@ class Config:
enable_http_actions: bool = field( enable_http_actions: bool = field(
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_HTTP_ACTIONS", False) 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 --- # --- mail ---
imap_host: str = field( imap_host: str = field(
+81 -6
View File
@@ -5,13 +5,22 @@ Status machines
proposal: proposed -> approved -> running -> done | failed proposal: proposed -> approved -> running -> done | failed
proposed -> rejected proposed -> rejected
mail: (inbound) classified: approval|rejection|question|other|needs_review mail: (inbound) classified: approval|rejection|question|other|needs_review
(outbound draft) none -> drafted -> approved -> sent | skipped (any) draft: none -> drafted -> approved -> sent | skipped
content: draft -> approved -> published 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 from __future__ import annotations
import enum import enum
import hashlib
import json
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text
@@ -22,6 +31,11 @@ def utcnow() -> datetime:
return datetime.now(UTC) 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): class Base(DeclarativeBase):
pass pass
@@ -54,7 +68,8 @@ class Proposal(Base):
title: Mapped[str] = mapped_column(String(512)) title: Mapped[str] = mapped_column(String(512))
url: Mapped[str | None] = mapped_column(String(1024), nullable=True) url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
detail: Mapped[dict] = mapped_column(JSON, default=dict) 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( status: Mapped[ProposalStatus] = mapped_column(
Enum(ProposalStatus), default=ProposalStatus.PROPOSED, index=True Enum(ProposalStatus), default=ProposalStatus.PROPOSED, index=True
) )
@@ -68,7 +83,7 @@ class Proposal(Base):
) )
# --- audit ------------------------------------------------------------------- # --- audit (hash-chained) ----------------------------------------------------
class AuditEntry(Base): class AuditEntry(Base):
@@ -76,13 +91,16 @@ class AuditEntry(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=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( proposal_id: Mapped[int | None] = mapped_column(
ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True
) )
action: Mapped[str] = mapped_column(String(64)) # approve|reject|execute|classify|... action: Mapped[str] = mapped_column(String(64)) # approve|reject|execute|classify|...
detail: Mapped[dict] = mapped_column(JSON, default=dict) detail: Mapped[dict] = mapped_column(JSON, default=dict)
result: Mapped[str] = mapped_column(String(32), default="ok") # ok | error 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( def audit(
@@ -93,18 +111,68 @@ def audit(
detail: dict | None = None, detail: dict | None = None,
result: str = "ok", result: str = "ok",
) -> AuditEntry: ) -> 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( entry = AuditEntry(
ts=ts,
actor=actor, actor=actor,
action=action, action=action,
proposal_id=proposal_id, proposal_id=proposal_id,
detail=detail or {}, detail=detail or {},
result=result, result=result,
prev_hash=prev_hash,
entry_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
) )
sess.add(entry) sess.add(entry)
return 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 -------------------------------------------------------------------- # --- mail --------------------------------------------------------------------
@@ -151,6 +219,9 @@ class MailMessage(Base):
Enum(DraftStatus), default=DraftStatus.NONE Enum(DraftStatus), default=DraftStatus.NONE
) )
draft_reply: Mapped[str | None] = mapped_column(Text, nullable=True) 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) processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -161,6 +232,7 @@ class ContentStatus(enum.StrEnum):
DRAFT = "draft" DRAFT = "draft"
APPROVED = "approved" APPROVED = "approved"
PUBLISHED = "published" PUBLISHED = "published"
SKIPPED = "skipped" # terminal: disabled channel or attempt budget exhausted
class ContentItem(Base): class ContentItem(Base):
@@ -179,6 +251,9 @@ class ContentItem(Base):
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
post_ref: Mapped[str | None] = mapped_column(String(512), nullable=True) post_ref: Mapped[str | None] = mapped_column(String(512), nullable=True)
error: Mapped[str | None] = mapped_column(Text, 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) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+22
View File
@@ -0,0 +1,22 @@
-- Per-daemon least-privilege roles (audit critical finding 1, deployment half).
-- The app-level chain + signatures are in code; this locks the DB layer.
-- Apply on the production Postgres; adjust the connection limits.
CREATE ROLE cayce_scanner LOGIN PASSWORD 'set-from-env';
CREATE ROLE cayce_executor LOGIN PASSWORD 'set-from-env';
CREATE ROLE cayce_maild LOGIN PASSWORD 'set-from-env';
CREATE ROLE cayce_publisher LOGIN PASSWORD 'set-from-env';
CREATE ROLE cayce_monitor LOGIN PASSWORD 'set-from-env';
GRANT SELECT ON proposals, audit_log, mail_messages, content_items, daemon_runs TO cayce_monitor;
GRANT SELECT, INSERT, UPDATE ON proposals TO cayce_scanner; -- no DELETE
GRANT INSERT ON audit_log TO cayce_scanner; -- append-only
GRANT SELECT, UPDATE ON proposals TO cayce_executor; -- status transitions
GRANT INSERT ON audit_log TO cayce_executor; -- append-only
GRANT SELECT, INSERT, UPDATE ON mail_messages, proposals TO cayce_maild;
GRANT INSERT ON audit_log TO cayce_maild;
GRANT SELECT, UPDATE ON content_items TO cayce_publisher;
GRANT INSERT ON audit_log TO cayce_publisher;
GRANT SELECT ON daemon_runs TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher;
-- sequences
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor;
+5
View File
@@ -9,9 +9,14 @@ sudo -u cayce uv sync --frozen
sudo -u cayce cayceops db upgrade # alembic to head sudo -u cayce cayceops db upgrade # alembic to head
sudo cp deploy/systemd/cayce-ops@.service deploy/systemd/cayce-ops@.timer /etc/systemd/system/ sudo cp deploy/systemd/cayce-ops@.service deploy/systemd/cayce-ops@.timer /etc/systemd/system/
# Every daemon has a `run` subcommand now:
# scanner run · executor run · maild run (poll + send-approved)
# publisher run (publish-due) · monitor run (build)
# cadence per daemon (edit OnCalendar in each timer copy): # cadence per daemon (edit OnCalendar in each timer copy):
# scanner hourly executor */10 min maild */5 min # scanner hourly executor */10 min maild */5 min
# publisher */5 min monitor */1 min # publisher */5 min monitor */1 min
# NOTE: `publisher plan` (fills the calendar) is a SEPARATE timer:
# OnCalendar=daily -> ExecStart=/srv/cayce-ops/.venv/bin/cayceops publisher plan
for d in scanner executor maild publisher monitor; do for d in scanner executor maild publisher monitor; do
sudo cp deploy/systemd/cayce-ops@.timer /etc/systemd/system/cayce-ops@$d.timer sudo cp deploy/systemd/cayce-ops@.timer /etc/systemd/system/cayce-ops@$d.timer
done done
+1 -1
View File
@@ -6,7 +6,7 @@ services:
POSTGRES_PASSWORD: localdev-only-not-a-secret POSTGRES_PASSWORD: localdev-only-not-a-secret
POSTGRES_DB: cayce_ops POSTGRES_DB: cayce_ops
ports: ports:
- "5432:5432" - "127.0.0.1:5432:5432" # loopback only — Docker bypasses host firewalls
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
+26
View File
@@ -69,3 +69,29 @@ An action plugin is a Python function registered in
`http_post` exists but is inert unless `CAYCE_OPS_ENABLE_HTTP_ACTIONS=true`. `http_post` exists but is inert unless `CAYCE_OPS_ENABLE_HTTP_ACTIONS=true`.
*Cost:* no sandboxing of plugins — plugins are repo code, reviewed like *Cost:* no sandboxing of plugins — plugins are repo code, reviewed like
everything else. everything else.
## D10 — Audit chain is hash-chained; approval can require operator signature (post-audit)
`audit_log` rows chain sha256(prev_hash|fields); `verify_audit_chain` walks it,
the monitor can surface breaks. With `CAYCE_OPS_OPERATOR_PUBKEY` set,
`executor approve` REQUIRES a BIP-340 signature over
sha256("cayce-ops:approve:<id>") from the operator key — `--actor` becomes a
label, the key is the proof (the audit's "anyone can sign as anyone").
*Cost:* without a pubkey configured the gate degrades to the old free-text
actor; separate DB roles per daemon remain a deployment TODO (SQL to tighten
ships in deploy/roles.sql).
## D11 — Mail threading now has a real first-contact path (post-audit)
Sends create an OUTBOUND row (own Message-ID, In-Reply-To -> inbound);
inbound rows keep their original Message-ID. `maild compose` creates the
initial outreach draft for a proposal. Approval is bound to the draft text
via sha256 (`approved_hash`): an edited draft refuses to send until
re-approved. Each send commits in its own transaction — SMTP failure on
message N can no longer resurrect 1..N-1 as duplicates.
## D12 — IMAP \\Seen only after commit; content gates tighten (post-audit)
Messages are fetched unmarked and flagged \\Seen after the processing
transaction commits (crash mid-batch no longer loses mail). Publisher:
caption hash-bound at approval, disabled channels are terminal (`SKIPPED`),
publish failures burn a bounded attempt budget before abandonment.
`risk_score` renamed `match_score` (keyword relevance) and auto-approval
takes the HIGHEST score first.
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# The gate, wired as a real hook (audit medium finding: it did not self-install).
# Runs the exact CI pipeline; refuses the push on any failure.
set -e
cd "$(git rev-parse --show-toplevel)"
make check
echo "pre-push gate passed — safe to push"
+54
View File
@@ -32,6 +32,60 @@ def test_reject_records_actor(cfg, sess):
assert p.status == ProposalStatus.REJECTED assert p.status == ProposalStatus.REJECTED
def test_signed_approval_required_when_operator_key_configured(cfg, sess):
"""Critical finding 1: --actor was free text; now a key can be required."""
from coincurve import PrivateKey, PublicKey
from cayceops.components.executor import executor
secret = bytes.fromhex("22" * 32)
pubkey = PublicKey.from_secret(secret).format().hex()[2:]
p = _proposal()
sess.add(p)
sess.flush()
config = cfg(CAYCE_OPS_OPERATOR_PUBKEY=pubkey)
# no signature -> refused
assert executor.approve(p.id, "gustavo", config, sess, signature=None) == 2
# wrong key -> refused
bad = (
PrivateKey(bytes.fromhex("33" * 32))
.sign_schnorr(executor.approval_message(p.id), aux_randomness=b"\x00" * 32)
.hex()
)
assert executor.approve(p.id, "gustavo", config, sess, signature=bad) == 2
# correct key -> approved
good = (
PrivateKey(secret)
.sign_schnorr(executor.approval_message(p.id), aux_randomness=b"\x00" * 32)
.hex()
)
assert executor.approve(p.id, "gustavo", config, sess, signature=good) == 0
assert p.status is ProposalStatus.APPROVED
entry = sess.query(AuditEntry).filter_by(action="approve", result="ok").one()
assert "signature" in entry.detail
def test_auto_approve_orders_by_relevance_desc(cfg, sess):
"""Medium finding: ascending 'risk_score' approved noise first."""
low = _proposal(kind="content_gap", title="barely relevant")
low.match_score = 0.1
high = _proposal(kind="content_gap", title="highly relevant")
high.match_score = 0.9
sess.add_all([low, high])
sess.flush()
config = cfg(
CAYCE_OPS_AUTONOMOUS_MODE="true",
CAYCE_OPS_AUTONOMOUS_KINDS="content_gap",
CAYCE_OPS_AUTONOMOUS_DAILY_CAP="1",
)
executor._auto_approve(config, sess)
assert high.status is ProposalStatus.APPROVED
assert low.status is ProposalStatus.PROPOSED
def test_run_executes_only_approved(cfg, sess): def test_run_executes_only_approved(cfg, sess):
approved = _proposal(title="Approved item") approved = _proposal(title="Approved item")
proposed = _proposal(title="Proposed item") proposed = _proposal(title="Proposed item")
+207 -38
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace
from cayceops.components.maild import classify as clf from cayceops.components.maild import classify as clf
from cayceops.components.maild import maild from cayceops.components.maild import maild
from cayceops.models import ( from cayceops.models import (
@@ -16,13 +18,10 @@ from cayceops.models import (
OUTBOUND_MSGID = "<cayce-draft-1-1000@cayce-co.xyz>" OUTBOUND_MSGID = "<cayce-draft-1-1000@cayce-co.xyz>"
STUB_CFG = SimpleNamespace(mail_from="agent@cayce-co.xyz")
def _eml(
subject: str, def _eml(subject, body, in_reply_to=None, msgid=None) -> bytes:
body: str,
in_reply_to: str | None = None,
msgid: str | None = None,
) -> bytes:
lines = [ lines = [
"From: Partner Desk <partners@acme.example.org>", "From: Partner Desk <partners@acme.example.org>",
"To: agent@cayce-co.xyz", "To: agent@cayce-co.xyz",
@@ -36,6 +35,8 @@ def _eml(
def _proposal_with_sent_draft(sess) -> Proposal: def _proposal_with_sent_draft(sess) -> Proposal:
"""A proposal whose first-contact mail went through the REAL path:
compose -> approve -> send (creating a true OUTBOUND row)."""
p = Proposal( p = Proposal(
kind="partnership", kind="partnership",
title="Acme hotel affiliate program", title="Acme hotel affiliate program",
@@ -43,35 +44,68 @@ def _proposal_with_sent_draft(sess) -> Proposal:
) )
sess.add(p) sess.add(p)
sess.flush() sess.flush()
sess.add( mail_id = maild._compose(
MailMessage( p.id, "partners@acme.example.org", "[cayce] partnership inquiry", "Hello!", sess
direction=MailDirection.OUTBOUND,
message_id=OUTBOUND_MSGID,
to_addr="partners@acme.example.org",
subject="[cayce] partnership inquiry",
proposal_id=p.id,
draft_status=DraftStatus.SENT,
)
) )
assert maild._approve_draft(mail_id, "gustavo", sess) == 0
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
out = (
sess.query(MailMessage)
.filter_by(direction=MailDirection.OUTBOUND, proposal_id=p.id)
.one()
)
out.message_id = OUTBOUND_MSGID # deterministic id for the fixtures
sess.flush() sess.flush()
return p return p
def _fetcher(raws: list[bytes]):
return lambda cfg: [(bytes(str(i), "ascii"), raw) for i, raw in enumerate(raws)]
# --- classifier --------------------------------------------------------------- # --- classifier ---------------------------------------------------------------
def test_rules_classification(cfg): def test_rules_classification(cfg):
config = cfg() config = cfg()
ok = clf.classify("Your application was approved, welcome to the program", config) assert (
no = clf.classify("Unfortunately we cannot accept new partners", config) clf.classify("Your application was approved, welcome to the program", config)[0]
assert ok[0] is MailClassification.APPROVAL is MailClassification.APPROVAL
assert no[0] is MailClassification.REJECTION )
q = clf.classify("Could you provide your traffic numbers?", config) assert (
assert q[0] is MailClassification.QUESTION clf.classify("Unfortunately we cannot accept new partners", config)[0]
kind, confidence = clf.classify("grocery list", config) is MailClassification.REJECTION
)
assert (
clf.classify("Could you provide your traffic numbers?", config)[0]
is MailClassification.QUESTION
)
kind, _ = clf.classify("grocery list", config)
assert kind is MailClassification.OTHER or kind is MailClassification.NEEDS_REVIEW assert kind is MailClassification.OTHER or kind is MailClassification.NEEDS_REVIEW
def test_negated_approvals_are_rejections(cfg):
"""Critical finding 4: 'cannot accept'/'not approved' read as approvals."""
config = cfg()
cases = [
"We cannot accept your application",
"We are not accepting new partners at this time",
"Your application was not approved",
"We can't approve this request",
"We do not accept external affiliates",
]
for text in cases:
kind, confidence = clf.classify(text, config)
assert kind is MailClassification.REJECTION, f"{text!r} -> {kind}"
assert confidence >= 0.9
def test_llm_prompt_format_survives_json_braces():
"""Critical finding 4: .format() raised KeyError on the JSON sample."""
formatted = clf._LLM_PROMPT.format(body="x")
assert '{"classification"' in formatted # literal braces survive
def test_low_confidence_becomes_needs_review(cfg): def test_low_confidence_becomes_needs_review(cfg):
config = cfg(CAYCE_OPS_LLM_REVIEW_THRESHOLD="0.95") config = cfg(CAYCE_OPS_LLM_REVIEW_THRESHOLD="0.95")
kind, confidence = clf.classify("Your application was approved, welcome", config) kind, confidence = clf.classify("Your application was approved, welcome", config)
@@ -90,8 +124,9 @@ def test_poll_threads_approval_and_drafts_reply(cfg, sess):
in_reply_to=OUTBOUND_MSGID, in_reply_to=OUTBOUND_MSGID,
) )
stats = maild._poll(cfg(), sess, fetcher=lambda cfg: [raw]) stats, nums = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
assert stats["processed"] == 1 and stats["drafted"] == 1 assert stats["processed"] == 1 and stats["drafted"] == 1
assert len(nums) == 1
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one() inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert inbound.classification is MailClassification.APPROVAL assert inbound.classification is MailClassification.APPROVAL
@@ -107,16 +142,16 @@ def test_poll_idempotent_on_message_id(cfg, sess):
_proposal_with_sent_draft(sess) _proposal_with_sent_draft(sess)
raw = _eml("Re: hi", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID) raw = _eml("Re: hi", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
def fetcher(cfg): first, second = maild._poll(cfg(), sess, fetcher=_fetcher([raw])), maild._poll(
return [raw] cfg(), sess, fetcher=_fetcher([raw])
)
assert maild._poll(cfg(), sess, fetcher=fetcher)["processed"] == 1 assert first[0]["processed"] == 1
assert maild._poll(cfg(), sess, fetcher=fetcher)["skipped"] == 1 # noqa: E501 assert second[0]["skipped"] == 1
def test_unthreaded_approval_needs_review(cfg, sess): def test_unthreaded_approval_needs_review(cfg, sess):
raw = _eml("Great news", "Approved! Welcome aboard.") # no In-Reply-To raw = _eml("Great news", "Approved! Welcome aboard.") # no In-Reply-To
stats = maild._poll(cfg(), sess, fetcher=lambda cfg: [raw]) stats, _ = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one() inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert inbound.classification is MailClassification.NEEDS_REVIEW assert inbound.classification is MailClassification.NEEDS_REVIEW
assert stats["needs_review"] == 1 assert stats["needs_review"] == 1
@@ -126,7 +161,7 @@ def test_unthreaded_approval_needs_review(cfg, sess):
def test_question_gets_clarification_draft(cfg, sess): def test_question_gets_clarification_draft(cfg, sess):
_proposal_with_sent_draft(sess) _proposal_with_sent_draft(sess)
raw = _eml("Re: inquiry", "Could you provide your monthly traffic?", in_reply_to=OUTBOUND_MSGID) raw = _eml("Re: inquiry", "Could you provide your monthly traffic?", in_reply_to=OUTBOUND_MSGID)
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw]) maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one() inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert inbound.classification is MailClassification.QUESTION assert inbound.classification is MailClassification.QUESTION
assert inbound.draft_status is DraftStatus.DRAFTED assert inbound.draft_status is DraftStatus.DRAFTED
@@ -139,12 +174,14 @@ def test_question_gets_clarification_draft(cfg, sess):
def test_send_blocked_without_approval(cfg, sess): def test_send_blocked_without_approval(cfg, sess):
_proposal_with_sent_draft(sess) _proposal_with_sent_draft(sess)
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID) raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw]) # noqa: E731 (test stub) maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
sent = [] sent = []
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz") config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a)) stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
assert stats["sent"] == 0 and sent == [] assert stats["sent"] == 0 and sent == []
assert stats["blocked"] + stats["missing"] >= 0 # no crash paths
assert config.mail_from == "agent@cayce-co.xyz"
row = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one() row = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert row.draft_status is DraftStatus.DRAFTED assert row.draft_status is DraftStatus.DRAFTED
@@ -156,27 +193,159 @@ def test_send_requires_explicit_human_approval(cfg, sess):
"Approved, welcome to the program.", "Approved, welcome to the program.",
in_reply_to=OUTBOUND_MSGID, in_reply_to=OUTBOUND_MSGID,
) )
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw]) # noqa: E731 (test stub) maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one() inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
original_id = inbound.message_id
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0 assert maild._approve_draft(inbound.id, "gustavo", sess) == 0
sent = [] sent = []
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz") config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
def sender(cfg, to, subject, body, message_id, in_reply_to=None):
sent.append((to, subject, body, message_id, in_reply_to))
stats = maild._send_approved(config, sess, sender=sender)
assert stats["sent"] == 1 assert stats["sent"] == 1
assert inbound.draft_status is DraftStatus.SENT assert inbound.draft_status is DraftStatus.SENT
assert sent[0][1] == "partners@acme.example.org" to, subject, body, message_id, in_reply_to = sent[0]
assert to == "partners@acme.example.org"
assert in_reply_to == original_id # In-Reply-To threads to the inbound mail
row = sess.query(AuditEntry).filter_by(action="send_draft").one() # a real OUTBOUND row now exists (audit fix: they were never created)
assert "gustavo" in str(row.detail) out = (
sess.query(MailMessage)
.filter_by(direction=MailDirection.OUTBOUND, message_id=message_id)
.one()
)
assert out.in_reply_to == original_id
assert out.proposal_id == inbound.proposal_id
assert inbound.message_id == original_id # inbound id NOT overwritten
row = (
sess.query(AuditEntry)
.filter_by(action="send_draft")
.order_by(AuditEntry.id.desc())
.first()
)
assert row is not None and "gustavo" in str(row.detail)
def test_double_approve_is_rejected(cfg, sess): def test_double_approve_is_rejected(cfg, sess):
_proposal_with_sent_draft(sess) _proposal_with_sent_draft(sess)
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID) raw = _eml("Re: inquiry", "Approved, welcome.", in_reply_to=OUTBOUND_MSGID)
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw]) # noqa: E731 (test stub) maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one() inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0 assert maild._approve_draft(inbound.id, "gustavo", sess) == 0
assert maild._approve_draft(inbound.id, "gustavo", sess) == 1 assert maild._approve_draft(inbound.id, "gustavo", sess) == 1
# --- audit corrections ----------------------------------------------------------
def test_send_failure_does_not_resurrect_sent_mails(cfg, sess):
"""Critical finding 3: SMTP failure mid-batch rolled back the whole
transaction, re-sending already-delivered mails 5 minutes later."""
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
for i in range(3):
# unique first-contact thread per proposal (real compose -> send path)
p = Proposal(kind="partnership", title=f"Acme partner {i}")
sess.add(p)
sess.flush()
mail_id = maild._compose(
p.id, f"desk{i}@acme.example.org", f"[cayce] inquiry {i}", "Hello!", sess
)
assert maild._approve_draft(mail_id, "gustavo", sess) == 0
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
sess.flush()
out = sess.get(MailMessage, mail_id)
raw = _eml(
f"Re: {i}",
"Approved, welcome to the program.",
in_reply_to=out.message_id,
msgid=f"<acme-{i}@acme.example.org>",
)
maild._poll(config, sess, fetcher=_fetcher([raw]))
inbound = (
sess.query(MailMessage)
.filter_by(direction=MailDirection.INBOUND, message_id=f"<acme-{i}@acme.example.org>")
.one()
)
maild._approve_draft(inbound.id, "gustavo", sess)
sess.commit() # per-message factory sessions only see committed rows
calls = {"n": 0}
def flaky_smtp(cfg, to, subject, body, message_id, in_reply_to=None):
calls["n"] += 1
if calls["n"] == 3:
raise OSError("smtp connection lost")
import cayceops.db as appdb
stats = maild._send_approved(
config, sess, sender=flaky_smtp, factory=appdb.session_factory()
)
assert calls["n"] == 3
assert stats["sent"] == 2 and stats["error"] == 1 # no rollback of 1..2
still_approved = (
sess.query(MailMessage)
.filter_by(direction=MailDirection.INBOUND, draft_status=DraftStatus.APPROVED)
.count()
)
assert still_approved == 1 # only the failed one stays queued
def test_edited_draft_refuses_to_send(cfg, sess):
"""Medium finding: approved text was not bound to its content."""
_proposal_with_sent_draft(sess)
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0
inbound.draft_reply = "TOTALLY EDITED TEXT after approval" # tamper
sent = []
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
assert stats["sent"] == 0 and stats["hash_mismatch"] == 1
assert sent == []
assert inbound.draft_status is DraftStatus.DRAFTED # needs fresh approval
action = sess.query(AuditEntry).filter_by(action="send_refused_hash_mismatch").one()
assert action.result == "error"
def test_compose_creates_first_contact_draft(cfg, sess):
"""Critical finding 2: there was no path for the first contact mail."""
p = Proposal(kind="partnership", title="New hotel partner")
sess.add(p)
sess.flush()
mail_id = maild._compose(
p.id, "desk@hotel.example.org", "Partnership", "We would like to...", sess
)
assert mail_id > 0
out = sess.get(MailMessage, mail_id)
assert out.direction is MailDirection.OUTBOUND
assert out.draft_status is DraftStatus.DRAFTED
assert out.proposal_id == p.id
# approve + send -> an inbound reply can now thread to this proposal
assert maild._approve_draft(mail_id, "gustavo", sess) == 0
stats = maild._send_approved(STUB_CFG, sess, sender=lambda *a, **k: None)
assert stats["sent"] == 1
sess.flush()
reply = _eml("Re: Partnership", "Approved, welcome!", in_reply_to=out.message_id)
assert maild._match_proposal(maild.parse_raw(reply), sess) is p
def test_mark_seen_returned_for_post_commit_processing(cfg, sess):
"""Medium finding: \\\\Seen was set before processing (crash lost mail)."""
raw = _eml("Re: hi", "Approved, welcome to the program.")
stats, nums = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
assert stats["processed"] == 1
assert nums == [b"0"] # caller marks seen only after commit
+20
View File
@@ -90,3 +90,23 @@ def test_mail_and_content_and_runs(sess):
assert m.id and c.id and r.id assert m.id and c.id and r.id
assert m.draft_status == "drafted" assert m.draft_status == "drafted"
assert c.status == "draft" assert c.status == "draft"
def test_audit_chain_and_verification(sess):
from cayceops.models import verify_audit_chain
p = Proposal(kind="other", title="chain")
sess.add(p)
sess.flush()
for i in range(3):
audit(sess, actor=f"actor-{i}", action="step", proposal_id=p.id, detail={"i": i})
sess.flush()
ok, broken = verify_audit_chain(sess)
assert ok and broken is None
# tamper with one entry -> chain breaks at that id
first = sess.query(AuditEntry).order_by(AuditEntry.id).first()
first.actor = "tampered"
ok, broken = verify_audit_chain(sess)
assert not ok and broken == first.id
+63 -3
View File
@@ -125,14 +125,14 @@ def test_nostr_without_config_fails_clean(cfg, sess):
assert item.error and "nostr" in item.error assert item.error and "nostr" in item.error
def test_non_nostr_channels_skipped_by_default(cfg, sess): def test_non_nostr_channels_skipped_terminal(cfg, sess):
item = _item(channel="facebook", theme="lightning-abroad") item = _item(channel="facebook", theme="lightning-abroad")
sess.add(item) sess.add(item)
sess.flush() sess.flush()
stats = publisher._publish_due(cfg(), sess) stats = publisher._publish_due(cfg(), sess)
assert stats["skipped"] == 1 assert stats["skipped"] == 1
assert item.status is ContentStatus.APPROVED assert item.status is ContentStatus.SKIPPED # terminal (audit fix)
skip = sess.query(AuditEntry).filter_by(action="publish_skipped").one() skip = sess.query(AuditEntry).filter_by(action="publish_skipped_terminal").one()
assert skip.detail["channel"] == "facebook" assert skip.detail["channel"] == "facebook"
@@ -162,3 +162,63 @@ def test_unknown_brand_rejected():
with pytest.raises(ValueError): with pytest.raises(ValueError):
images.render_brand_image(brand="xx", kicker=None, lines=["A"], out_path="/tmp/x.jpg") images.render_brand_image(brand="xx", kicker=None, lines=["A"], out_path="/tmp/x.jpg")
# --- audit corrections ----------------------------------------------------------
def test_publish_refuses_tampered_caption(cfg, sess):
"""Medium finding: approved caption was not bound to content."""
item = _item(status=ContentStatus.DRAFT)
sess.add(item)
sess.flush()
assert publisher._approve(item.id, "gustavo", sess) == 0
item.caption = item.caption + " TAMPERED"
config = cfg(CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org")
stats = publisher._publish_due(config, sess, nostr_sender=lambda r, e, timeout=10: [])
assert stats["published"] == 0 and stats["tampered"] == 1
assert item.status is ContentStatus.APPROVED # stays, needs re-approval
action = sess.query(AuditEntry).filter_by(action="publish_refused_hash_mismatch").one()
assert action.result == "error"
def test_disabled_channel_is_terminal(cfg, sess):
"""Medium finding: disabled channels wrote skip audits every 5 minutes."""
item = _item(channel="facebook", theme="lightning-abroad")
sess.add(item)
sess.flush()
config = cfg()
stats1 = publisher._publish_due(config, sess)
assert stats1["skipped"] == 1
assert item.status is ContentStatus.SKIPPED # terminal
stats2 = publisher._publish_due(config, sess)
assert stats2["skipped"] == 0 # never revisited
skips = sess.query(AuditEntry).filter_by(action="publish_skipped_terminal").count()
assert skips == 1
def test_publish_attempts_bounded(cfg, sess, monkeypatch):
"""Medium finding: nostr failures retried without limit."""
from cayceops.components.publisher import nostr as nostr_mod
item = _item()
sess.add(item)
sess.flush()
def always_fail(relays, event, timeout=10.0):
raise RuntimeError("relay down")
monkeypatch.setattr(nostr_mod, "publish", always_fail)
config = cfg(
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org",
CAYCE_OPS_MAX_ATTEMPTS="1",
)
publisher._publish_due(config, sess, nostr_sender=always_fail)
assert item.attempts == 1
assert item.status is ContentStatus.SKIPPED # budget exhausted -> terminal
+1 -1
View File
@@ -58,7 +58,7 @@ def test_scan_files_proposals_with_kind_and_score(cfg, sess):
affiliate = by_title["Acme launches affiliate program for hotels"] affiliate = by_title["Acme launches affiliate program for hotels"]
assert affiliate.kind == "affiliate" assert affiliate.kind == "affiliate"
assert affiliate.status == ProposalStatus.PROPOSED assert affiliate.status == ProposalStatus.PROPOSED
assert affiliate.risk_score > 0 assert affiliate.match_score > 0
assert affiliate.dedupe_key assert affiliate.dedupe_key
partner = by_title["Become a payment partner"] partner = by_title["Become a payment partner"]