audit-1 (critical 1): mandatory operator signatures bound to content+deployment

- signing module: canonical content hashes + approval_message
  sha256(cayce-ops:<type>:<deployment_id>:<id>:<content_hash>)
- approve/approve-draft/publisher-approve REQUIRE signature; without
  CAYCE_OPS_OPERATOR_PUBKEY+DEPLOYMENT_ID the daemons refuse to
  execute/send/publish (no degraded mode)
- run-time re-verification at execute/send/publish: unsigned rows (raw SQL
  approval) or edited content revert to pre-approval state, audited as error
- FULL signatures stored on rows (operator_signature, migration 0003) and
  in audit detail — never truncated
- signature failures audited from CLI wrappers too (1f)
- AUTONOMOUS_MODE approval path removed (D13): no non-human approvals

tests: SQL-approved row refused at execute/send/publish; to_addr change
blocks send; foreign deployment/content signatures refused; full-signature
persistence. 65 tests green.
This commit is contained in:
2026-10-20 02:20:09 +00:00
parent 61d5cfd67d
commit 40453aadb4
13 changed files with 1209 additions and 273 deletions
+156 -41
View File
@@ -39,6 +39,21 @@ from cayceops.models import (
audit,
)
GATE_ERROR = 2 # missing/invalid configuration or signature
def _signing(cfg: Config) -> tuple[str, str]:
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode."""
from cayceops.config import ConfigError
if not cfg.operator_pubkey or not cfg.deployment_id:
raise ConfigError(
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
"CAYCE_OPS_DEPLOYMENT_ID are both required"
)
return cfg.deployment_id, cfg.operator_pubkey
# --- IMAP ---------------------------------------------------------------------
@@ -252,13 +267,32 @@ def _poll(cfg: Config, sess, fetcher=None, marker=None) -> tuple[dict, list[byte
# --- human gate: approve draft -------------------------------------------------
def _approve_draft(mail_id: int, actor: str, sess) -> int:
def _mail_to_addr(record) -> str | None:
"""The address a send would use for this draft."""
if record.direction == MailDirection.INBOUND:
return record.from_addr
return record.to_addr
def _mail_content_hash(record) -> str:
from cayceops.signing import mail_content_hash
return mail_content_hash(
_mail_to_addr(record), record.subject, record.draft_reply
)
def _approve_draft(
mail_id: int, actor: str, sess, signature: str | None = None
) -> int:
"""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).
Signature is MANDATORY and bound to (to_addr, subject, body) + deployment
(audit round 2, 1a/1b). The full signature is stored on the row.
"""
from cayceops.config import ConfigError
from cayceops.signing import SignatureError, verify_signature
record = sess.get(MailMessage, mail_id)
if record is None:
return 1
@@ -272,15 +306,52 @@ def _approve_draft(mail_id: int, actor: str, sess) -> int:
detail={"reason": f"invalid draft state: {record.draft_status.value}"},
)
return 1
from cayceops.config import load
cfg = load()
try:
deployment_id, pubkey = _signing(cfg)
except ConfigError as exc:
audit(
sess,
actor=actor,
action="approve_draft",
proposal_id=record.proposal_id,
result="error",
detail={"reason": str(exc)},
)
return GATE_ERROR
chash = _mail_content_hash(record)
try:
if not signature:
raise SignatureError("signature required")
if not verify_signature(
"mail", deployment_id, mail_id, chash, signature, pubkey
):
raise SignatureError("signature invalid for this content/deployment")
except SignatureError as exc:
audit(
sess,
actor=actor,
action="approve_draft",
proposal_id=record.proposal_id,
result="error",
detail={"reason": str(exc), "content_hash": chash},
)
return GATE_ERROR
record.draft_status = DraftStatus.APPROVED
record.approved_hash = _content_hash(record.draft_reply)
record.approved_by = actor[:128]
record.operator_signature = signature # FULL signature
audit(
sess,
actor=actor,
action="approve_draft",
proposal_id=record.proposal_id,
detail={"mail_id": record.id, "hash": record.approved_hash},
detail={"mail_id": record.id, "hash": record.approved_hash, "signature": signature},
)
return 0
@@ -328,7 +399,9 @@ def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
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
from cayceops.config import ConfigError
from cayceops.signing import verify_signature
record = sess.get(MailMessage, mail_id)
if record is None:
@@ -336,6 +409,41 @@ def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
if record.draft_status != DraftStatus.APPROVED:
return "blocked"
# run-time signature gate (audit round 2, 1d): the row must carry a valid
# operator signature for its CURRENT to_addr/subject/body + deployment.
# Unsigned rows (raw SQL approval) or edited ones revert to DRAFTED.
try:
deployment_id, pubkey = _signing(cfg)
except ConfigError as exc:
audit(
sess,
actor="daemon:maild",
action="send_refused",
proposal_id=record.proposal_id,
result="error",
detail={"mail_id": record.id, "reason": str(exc)},
)
return "unsigned"
current_hash = _mail_content_hash(record)
if not verify_signature(
"mail", deployment_id, record.id, current_hash, record.operator_signature, pubkey
):
record.draft_status = DraftStatus.DRAFTED # back to pre-approval
audit(
sess,
actor="daemon:maild",
action="send_refused",
proposal_id=record.proposal_id,
result="error",
detail={
"mail_id": record.id,
"reason": "no valid operator signature for current content/deployment",
"content_hash": current_hash,
"had_signature": bool(record.operator_signature),
},
)
return "hash_mismatch"
if record.direction == MailDirection.INBOUND:
# reply: goes back to the inbound sender, threads to the inbound mail,
# and gets its own OUTBOUND row
@@ -357,19 +465,6 @@ def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
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_refused_hash_mismatch",
proposal_id=record.proposal_id,
result="error",
detail={"mail_id": record.id},
)
return "hash_mismatch"
(sender or _smtp_send)(
cfg,
to_addr,
@@ -436,10 +531,22 @@ def _send_approved(cfg: Config, sess, sender=None, factory=None) -> dict:
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)
# persist the failure in its OWN transaction (the failed message's
# transaction is dead)
try:
error_sess = factory() if factory is not None else None
if error_sess is not None:
audit(
error_sess,
actor="daemon:maild",
action="send_failed",
result="error",
detail={"mail_id": mail_id, "error": str(exc)[:200]},
)
error_sess.commit()
error_sess.close()
except Exception: # noqa: S110 — best-effort failure audit
pass
stats[outcome] = stats.get(outcome, 0) + 1
return stats
@@ -517,40 +624,48 @@ def poll(cfg: Config | None = None) -> int:
engine().dispose()
def approve_draft(mail_id: int, actor: str) -> int:
def approve_draft(mail_id: int, actor: str, signature: str | None = None) -> int:
from cayceops.db import engine
try:
with session_scope() as sess:
return _approve_draft(mail_id, actor, sess)
return _approve_draft(mail_id, actor, sess, signature)
finally:
engine().dispose()
def send_approved(cfg: Config | None = None) -> int:
"""CLI: same code path as the tests (_send_approved with factory).
Failures are audited (send_failed), recorded in daemon_runs, and any
error yields a non-zero exit code (audit round 2, point 5).
"""
from cayceops.config import load
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:
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:
with session_scope() as sess:
run_row = DaemonRun(daemon="maild-send")
sess.add(run_row)
sess.flush()
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
_signing(cfg) # no degraded mode
except Exception as exc: # noqa: BLE001 — ConfigError
run_row.status = "error"
run_row.detail = {"refused": str(exc)[:200]}
run_row.finished_at = datetime.now(UTC)
print(f"send refused: {exc}")
return 2
stats = _send_approved(cfg, sess, factory=factory)
run_row.status = "error" if stats.get("error") else "ok"
run_row.detail = stats
run_row.finished_at = datetime.now(UTC)
print(stats)
# any failure class is a non-zero exit (audit round 2, point 5)
failures = stats.get("error") or stats.get("hash_mismatch") or stats.get("unsigned")
return 1 if failures else 0
finally:
engine().dispose()