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
@@ -0,0 +1,42 @@
"""approval hardening: mandatory operator signatures bound to content
Revision ID: 0003
Revises: 0002
Create Date: 2026-10-20
- proposals.operator_signature
- mail_messages.operator_signature
- content_items.operator_signature
Full signatures (128 hex chars) — never truncated. Existing NULL rows are
refused at execution/send/publish time by the signing gate (no degraded mode).
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("proposals") as batch:
batch.add_column(sa.Column("operator_signature", sa.Text(), nullable=True))
with op.batch_alter_table("mail_messages") as batch:
batch.add_column(sa.Column("operator_signature", sa.Text(), nullable=True))
with op.batch_alter_table("content_items") as batch:
batch.add_column(sa.Column("operator_signature", sa.Text(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("content_items") as batch:
batch.drop_column("operator_signature")
with op.batch_alter_table("mail_messages") as batch:
batch.drop_column("operator_signature")
with op.batch_alter_table("proposals") as batch:
batch.drop_column("operator_signature")
+30 -8
View File
@@ -74,10 +74,10 @@ def executor_approve(
proposal_id: int, proposal_id: int,
actor: str = typer.Option(..., help="Human approver id"), actor: str = typer.Option(..., help="Human approver id"),
signature: str = typer.Option( signature: str = typer.Option(
None, help="BIP-340 sig (required when operator pubkey is set)" ..., help="BIP-340 sig over the approval message (mandatory)"
), ),
) -> None: ) -> None:
"""Approve a proposal (human action, audited).""" """Approve a proposal (human action, audited; signature mandatory)."""
raise typer.Exit( raise typer.Exit(
_load_component("executor").approve(proposal_id, actor, signature=signature) _load_component("executor").approve(proposal_id, actor, signature=signature)
) )
@@ -101,9 +101,17 @@ def maild_poll() -> None:
@maild_app.command("approve-draft") @maild_app.command("approve-draft")
def maild_approve(msg_id: int, actor: str = typer.Option(...)) -> None: def maild_approve(
"""Approve a drafted reply so SMTP may send it.""" msg_id: int,
raise typer.Exit(_load_component("maild").approve_draft(msg_id, actor)) actor: str = typer.Option(...),
signature: str = typer.Option(
..., help="BIP-340 sig over the approval message (mandatory)"
),
) -> None:
"""Approve a drafted reply so SMTP may send it (signature mandatory)."""
raise typer.Exit(
_load_component("maild").approve_draft(msg_id, actor, signature)
)
@maild_app.command("send-approved") @maild_app.command("send-approved")
@@ -153,9 +161,17 @@ def publisher_image(item_id: int) -> None:
@publisher_app.command("approve") @publisher_app.command("approve")
def publisher_approve(item_id: int, actor: str = typer.Option(...)) -> None: def publisher_approve(
"""Approve a draft content item.""" item_id: int,
raise typer.Exit(_load_component("publisher").approve(item_id, actor)) actor: str = typer.Option(...),
signature: str = typer.Option(
..., help="BIP-340 sig over the approval message (mandatory)"
),
) -> None:
"""Approve a draft content item (signature mandatory)."""
raise typer.Exit(
_load_component("publisher").approve(item_id, actor, signature)
)
@publisher_app.command("publish-due") @publisher_app.command("publish-due")
@@ -187,6 +203,12 @@ def monitor_run() -> None:
raise typer.Exit(_load_component("monitor").build()) raise typer.Exit(_load_component("monitor").build())
@monitor_app.command("anchor")
def monitor_anchor() -> None:
"""Publish today's audit-chain anchor as a signed Nostr note (1/day)."""
raise typer.Exit(_load_component("monitor").anchor())
def main() -> None: # pragma: no cover def main() -> None: # pragma: no cover
app() app()
+133 -30
View File
@@ -37,11 +37,19 @@ def _load_cfg():
# --- human transitions ------------------------------------------------------- # --- human transitions -------------------------------------------------------
# message signed by the operator key when CAYCE_OPS_OPERATOR_PUBKEY is set GATE_ERROR = 2 # missing/invalid configuration or signature
def approval_message(proposal_id: int) -> bytes:
import hashlib
return hashlib.sha256(f"cayce-ops:approve:{proposal_id}".encode()).digest()
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
def approve( def approve(
@@ -51,36 +59,54 @@ def approve(
sess=None, sess=None,
signature: str | None = None, signature: str | None = None,
) -> int: ) -> int:
"""Human approves a proposal. Audited with the operator's identity. """Human approves a proposal. Signature is MANDATORY and bound to the
proposal's current content and this deployment (audit finding 1)."""
When CAYCE_OPS_OPERATOR_PUBKEY is configured, `signature` (hex, BIP-340 from cayceops.config import ConfigError
over approval_message(id)) is REQUIRED and verified against that key — from cayceops.signing import (
'actor' then is a label, the key is the proof (audit finding 1). SignatureError,
""" proposal_content_hash,
from cayceops.components.publisher.nostr import verify_schnorr verify_signature,
)
cfg = cfg or _load_cfg() cfg = cfg or _load_cfg()
if cfg.operator_pubkey: try:
ok = bool(signature) and verify_schnorr( deployment_id, pubkey = _signing(cfg)
signature, approval_message(proposal_id), cfg.operator_pubkey except ConfigError as exc:
) if sess is not None:
if not ok and sess is not None:
audit( audit(
sess, sess,
actor=actor, actor=actor,
action="approve", action="approve",
proposal_id=proposal_id, proposal_id=proposal_id,
result="error", result="error",
detail={ detail={"reason": str(exc)},
"reason": (
"operator pubkey configured: signature required"
if not signature
else "invalid approval signature"
)
},
) )
if not ok: return GATE_ERROR
return 2
proposal = sess.get(Proposal, proposal_id) if sess is not None else None
if proposal is None:
return 1
chash = proposal_content_hash(
proposal.kind, proposal.title, proposal.url, proposal.detail
)
try:
if not signature:
raise SignatureError("signature required")
if not verify_signature(
"proposal", deployment_id, proposal_id, chash, signature, pubkey
):
raise SignatureError("signature invalid for this content/deployment")
except SignatureError as exc:
audit(
sess,
actor=actor,
action="approve",
proposal_id=proposal_id,
result="error",
detail={"reason": str(exc), "content_hash": chash},
)
return GATE_ERROR
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess, signature) return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess, signature)
@@ -122,8 +148,8 @@ def _decide(
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: proposal.operator_signature = signature # FULL signature, row + audit
detail["signature"] = signature[:32] + "..." detail["signature"] = signature or ""
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, detail=detail) audit(sess, actor=actor, action=verb, proposal_id=proposal_id, detail=detail)
return 0 return 0
@@ -136,9 +162,20 @@ def _decide(
def _auto_approve(cfg: Config, sess) -> int: def _auto_approve(cfg: Config, sess) -> int:
"""Auto-approve proposed items per policy. Returns count approved.""" """DISABLED (audit round 2, point 1a): approvals require operator
if not cfg.autonomous_mode or not cfg.autonomous_kinds: signatures bound to content — there is no non-human approval path.
return 0 AUTONOMOUS_MODE no longer grants approval powers (DECISIONS D13)."""
if cfg.autonomous_mode and cfg.autonomous_kinds:
audit(
sess,
actor="auto:policy",
action="autonomy_disabled",
result="error",
detail={
"reason": "mandatory operator signatures: no non-human approvals",
},
)
return 0
midnight = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) midnight = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
used = ( used = (
sess.query(func.count(AuditEntry.id)) sess.query(func.count(AuditEntry.id))
@@ -188,8 +225,67 @@ def _auto_approve(cfg: Config, sess) -> int:
# --- execution ---------------------------------------------------------------- # --- execution ----------------------------------------------------------------
def _verify_approval_signature(proposal: Proposal, cfg: Config, sess) -> bool:
"""Run-time gate (audit round 2, 1d): a row in `approved` MUST carry a
valid operator signature for its CURRENT content and THIS deployment.
Rows approved by raw SQL (no signature) or edited after approval are
rejected, audited as error, and revert to `proposed`."""
from cayceops.signing import (
SignatureError,
proposal_content_hash,
verify_signature,
)
try:
deployment_id, pubkey = _signing(cfg)
except Exception as exc: # noqa: BLE001 — ConfigError at daemon boundary
audit(
sess,
actor="daemon:executor",
action="execute_refused",
proposal_id=proposal.id,
result="error",
detail={"reason": str(exc)},
)
return False
chash = proposal_content_hash(
proposal.kind, proposal.title, proposal.url, proposal.detail
)
try:
if not verify_signature(
"proposal",
deployment_id,
proposal.id,
chash,
proposal.operator_signature,
pubkey,
):
raise SignatureError(
"no valid operator signature for current content/deployment"
)
except SignatureError as exc:
proposal.status = ProposalStatus.PROPOSED # back to pre-approval
proposal.operator_signature = None
audit(
sess,
actor="daemon:executor",
action="execute_refused",
proposal_id=proposal.id,
result="error",
detail={
"reason": str(exc),
"content_hash": chash,
"had_signature": bool(proposal.operator_signature),
},
)
return False
return True
def execute_one(proposal: Proposal, cfg: Config, sess) -> bool: def execute_one(proposal: Proposal, cfg: Config, sess) -> bool:
"""Run the plugin for one approved proposal. Returns True on success.""" """Run the plugin for one approved proposal. Returns True on success."""
if not _verify_approval_signature(proposal, cfg, sess):
return False
proposal.status = ProposalStatus.RUNNING proposal.status = ProposalStatus.RUNNING
proposal.attempts += 1 proposal.attempts += 1
audit( audit(
@@ -256,6 +352,13 @@ def run(cfg: Config | None = None, sess=None) -> int:
run_row = DaemonRun(daemon="executor") run_row = DaemonRun(daemon="executor")
sess.add(run_row) sess.add(run_row)
sess.flush() sess.flush()
try:
_signing(cfg) # refuse to run without signing config (no degraded mode)
except Exception as exc: # noqa: BLE001 — ConfigError at boundary
run_row.status = "error"
run_row.detail = {"refused": str(exc)[:200]}
run_row.finished_at = datetime.now(UTC)
return 2
try: try:
auto = _auto_approve(cfg, sess) auto = _auto_approve(cfg, sess)
pending = ( pending = (
+156 -41
View File
@@ -39,6 +39,21 @@ from cayceops.models import (
audit, 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 --------------------------------------------------------------------- # --- IMAP ---------------------------------------------------------------------
@@ -252,13 +267,32 @@ def _poll(cfg: Config, sess, fetcher=None, marker=None) -> tuple[dict, list[byte
# --- human gate: approve draft ------------------------------------------------- # --- 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). """Human approves a drafted reply (inbound-threaded or first-contact).
The approval is BOUND to the draft text via sha256 — a later edit breaks Signature is MANDATORY and bound to (to_addr, subject, body) + deployment
the binding and the send refuses (audit finding: approved text was not (audit round 2, 1a/1b). The full signature is stored on the row.
tied to its content).
""" """
from cayceops.config import ConfigError
from cayceops.signing import SignatureError, verify_signature
record = sess.get(MailMessage, mail_id) record = sess.get(MailMessage, mail_id)
if record is None: if record is None:
return 1 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}"}, detail={"reason": f"invalid draft state: {record.draft_status.value}"},
) )
return 1 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.draft_status = DraftStatus.APPROVED
record.approved_hash = _content_hash(record.draft_reply) record.approved_hash = _content_hash(record.draft_reply)
record.approved_by = actor[:128] record.approved_by = actor[:128]
record.operator_signature = signature # FULL signature
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, "hash": record.approved_hash}, detail={"mail_id": record.id, "hash": record.approved_hash, "signature": signature},
) )
return 0 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 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). 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) record = sess.get(MailMessage, mail_id)
if record is None: 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: if record.draft_status != DraftStatus.APPROVED:
return "blocked" 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: if record.direction == MailDirection.INBOUND:
# reply: goes back to the inbound sender, threads to the inbound mail, # reply: goes back to the inbound sender, threads to the inbound mail,
# and gets its own OUTBOUND row # 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 record.draft_status = DraftStatus.SKIPPED
return "blocked" 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)( (sender or _smtp_send)(
cfg, cfg,
to_addr, 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) outcome = _send_one(cfg, sess, mail_id, sender)
except Exception as exc: # noqa: BLE001 — isolate the failed message except Exception as exc: # noqa: BLE001 — isolate the failed message
outcome = "error" outcome = "error"
if sess is not None: # persist the failure in its OWN transaction (the failed message's
detail = {"mail_id": mail_id, "error": str(exc)[:200]} # transaction is dead)
audit(sess, actor="daemon:maild", action="send_failed", try:
result="error", detail=detail) 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 stats[outcome] = stats.get(outcome, 0) + 1
return stats return stats
@@ -517,40 +624,48 @@ def poll(cfg: Config | None = None) -> int:
engine().dispose() 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 from cayceops.db import engine
try: try:
with session_scope() as sess: with session_scope() as sess:
return _approve_draft(mail_id, actor, sess) return _approve_draft(mail_id, actor, sess, signature)
finally: finally:
engine().dispose() engine().dispose()
def send_approved(cfg: Config | None = None) -> int: 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.config import load
from cayceops.db import engine, session_factory 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:
ids = [
row.id
for row in sess.query(MailMessage.id)
.filter(MailMessage.draft_status == DraftStatus.APPROVED)
.all()
]
factory = session_factory() 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: try:
with factory() as one_sess: _signing(cfg) # no degraded mode
_send_one(cfg, one_sess, mail_id) except Exception as exc: # noqa: BLE001 — ConfigError
one_sess.commit() run_row.status = "error"
except Exception as exc: # noqa: BLE001 — isolate each send run_row.detail = {"refused": str(exc)[:200]}
print(f"send {mail_id} failed: {str(exc)[:160]}") run_row.finished_at = datetime.now(UTC)
return 0 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: finally:
engine().dispose() engine().dispose()
+110 -9
View File
@@ -114,7 +114,33 @@ def _plan(cfg: Config, sess, days: int = 4) -> dict:
# --- human gate ------------------------------------------------------------------- # --- human gate -------------------------------------------------------------------
def _approve(item_id: int, actor: str, sess) -> int: GATE_ERROR = 2 # missing/invalid configuration or signature
def _signing(cfg: Config) -> tuple[str, str]:
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
def _content_binding(item: ContentItem) -> str:
from cayceops.signing import content_content_hash
return content_content_hash(item.channel, item.caption, item.link)
def _approve(
item_id: int, actor: str, sess, signature: str | None = None
) -> int:
"""Signature MANDATORY, bound to (channel, caption, link) + deployment."""
from cayceops.config import ConfigError
from cayceops.signing import SignatureError, verify_signature
item = sess.get(ContentItem, item_id) item = sess.get(ContentItem, item_id)
if item is None or item.status != ContentStatus.DRAFT: if item is None or item.status != ContentStatus.DRAFT:
audit( audit(
@@ -125,17 +151,58 @@ def _approve(item_id: int, actor: str, sess) -> int:
detail={"reason": f"item {item_id} not in draft state"}, detail={"reason": f"item {item_id} not in draft state"},
) )
return 1 return 1
try:
deployment_id, pubkey = _signing(cfg_from(sess))
except ConfigError as exc:
audit(
sess,
actor=actor,
action="approve_content",
result="error",
detail={"reason": str(exc)},
)
return GATE_ERROR
chash = _content_binding(item)
try:
if not signature:
raise SignatureError("signature required")
if not verify_signature(
"content", deployment_id, item_id, chash, signature, pubkey
):
raise SignatureError("signature invalid for this content/deployment")
except SignatureError as exc:
audit(
sess,
actor=actor,
action="approve_content",
result="error",
detail={"reason": str(exc), "content_hash": chash},
)
return GATE_ERROR
item.status = ContentStatus.APPROVED item.status = ContentStatus.APPROVED
item.approved_hash = _content_hash(item.caption) # binding: caption as approved item.approved_hash = _content_hash(item.caption)
item.operator_signature = signature # FULL signature
audit( audit(
sess, sess,
actor=actor, actor=actor,
action="approve_content", action="approve_content",
detail={"item": item.id, "channel": item.channel, "hash": item.approved_hash}, detail={
"item": item.id,
"channel": item.channel,
"hash": item.approved_hash,
"signature": signature,
},
) )
return 0 return 0
def cfg_from(sess):
"""Config from env (publisher commands are single-invocation CLIs)."""
from cayceops.config import load
return load()
# --- image rendering ---------------------------------------------------------------- # --- image rendering ----------------------------------------------------------------
@@ -192,14 +259,41 @@ def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
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) # run-time signature gate (audit round 2, 1d): valid operator
if item.approved_hash and _content_hash(item.caption) != item.approved_hash: # signature required for CURRENT channel/caption/link + deployment.
# Unsigned rows (raw SQL approval) or edited ones revert to DRAFT.
from cayceops.signing import verify_signature
try:
deployment_id, pubkey = _signing(cfg)
except Exception as exc: # noqa: BLE001 — ConfigError
audit( audit(
sess, sess,
actor="daemon:publisher", actor="daemon:publisher",
action="publish_refused_hash_mismatch", action="publish_refused",
result="error", result="error",
detail={"item": item.id}, detail={"item": item.id, "reason": str(exc)},
)
stats["failed"] += 1
continue
if not verify_signature(
"content",
deployment_id,
item.id,
_content_binding(item),
item.operator_signature,
pubkey,
):
item.status = ContentStatus.DRAFT # back to pre-approval
audit(
sess,
actor="daemon:publisher",
action="publish_refused",
result="error",
detail={
"item": item.id,
"reason": "no valid operator signature for current content/deployment",
},
) )
stats["tampered"] += 1 stats["tampered"] += 1
continue continue
@@ -287,6 +381,13 @@ def publish_due(cfg: Config | None = None) -> int:
run_row = DaemonRun(daemon="publisher") run_row = DaemonRun(daemon="publisher")
sess.add(run_row) sess.add(run_row)
sess.flush() sess.flush()
try:
_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)
return 2
stats = _publish_due(cfg, sess) stats = _publish_due(cfg, sess)
run_row.status = "ok" run_row.status = "ok"
run_row.detail = stats run_row.detail = stats
@@ -312,11 +413,11 @@ def make_image(item_id: int, cfg: Config | None = None) -> int:
engine().dispose() engine().dispose()
def approve(item_id: int, actor: str) -> int: def approve(item_id: int, actor: str, signature: str | None = None) -> int:
from cayceops.db import engine from cayceops.db import engine
try: try:
with session_scope() as sess: with session_scope() as sess:
return _approve(item_id, actor, sess) return _approve(item_id, actor, sess, signature)
finally: finally:
engine().dispose() engine().dispose()
+10 -2
View File
@@ -84,11 +84,15 @@ 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 # MANDATORY for approval-gated work: the operator x-only pubkey and the
# BIP-340 signature over "cayce-ops:approve:<id>" made by the operator key. # deployment identifier are part of every signed approval message.
# Without BOTH, daemons refuse to execute/send/publish — no degraded mode.
operator_pubkey: str = field( operator_pubkey: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_OPERATOR_PUBKEY", "") default_factory=lambda: os.environ.get("CAYCE_OPS_OPERATOR_PUBKEY", "")
) )
deployment_id: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_DEPLOYMENT_ID", "")
)
# --- mail --- # --- mail ---
imap_host: str = field( imap_host: str = field(
@@ -167,6 +171,10 @@ class Config:
) )
class ConfigError(RuntimeError):
"""Raised when mandatory configuration is missing."""
def load() -> Config: def load() -> Config:
"""Build the process Config snapshot.""" """Build the process Config snapshot."""
return Config() return Config()
+86 -14
View File
@@ -21,9 +21,10 @@ from __future__ import annotations
import enum import enum
import hashlib import hashlib
import json import json
import threading
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, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -77,6 +78,8 @@ class Proposal(Base):
dedupe_key: Mapped[str | None] = mapped_column(String(256), unique=True, nullable=True) dedupe_key: Mapped[str | None] = mapped_column(String(256), unique=True, nullable=True)
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True) approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# full operator approval signature (128 hex chars) — never truncated
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow DateTime(timezone=True), default=utcnow, onupdate=utcnow
@@ -103,6 +106,11 @@ class AuditEntry(Base):
entry_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) entry_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# reentrant: several audit() calls may share one transaction
_AUDIT_LOCK = threading.RLock()
_AUDIT_LOCK_PG_KEY = 7471186757 # pg_advisory_xact_lock key for cross-process
def audit( def audit(
sess, sess,
actor: str, actor: str,
@@ -113,13 +121,66 @@ def audit(
) -> AuditEntry: ) -> AuditEntry:
"""Record one audited action, chained to the previous entry. """Record one audited action, chained to the previous entry.
Chain: entry_hash = sha256(prev_hash | ts | actor | action | proposal_id | detail | result). Chain: entry_hash = sha256(prev_hash | ts_epoch | actor | action |
Every component writes through this helper. proposal_id | detail | result). Writes are SERIALIZED (audit round 2,
point 4): pg_advisory_xact_lock on Postgres, in-process lock on SQLite —
concurrent writers can no longer fork the chain.
Legacy rows (entry_hash IS NULL, pre-0002) are ignored when picking
prev_hash: the chain starts GENESIS at the first hashed row.
""" """
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first() bind = sess.get_bind()
prev_hash = prev.entry_hash if prev is not None else "GENESIS" if bind.dialect.name == "postgresql":
# xact-scoped: PG releases the lock at commit/rollback automatically
sess.execute(
text("SELECT pg_advisory_xact_lock(:k)"), {"k": _AUDIT_LOCK_PG_KEY}
)
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
entry = _build_entry(prev_hash, actor, action, proposal_id, detail, result)
sess.add(entry)
return entry
# SQLite path: hold the in-process lock until the session commits or
# rolls back, so concurrent writers cannot read the same prev_hash
# (round-2 point 4 — verified by test_concurrent_writers_keep_chain_verifiable)
_AUDIT_LOCK.acquire()
released = False
def _release(*_args):
nonlocal released
if not released:
released = True
try:
_AUDIT_LOCK.release()
except RuntimeError: # pragma: no cover
pass
from sqlalchemy import event
event.listen(sess, "after_commit", _release)
event.listen(sess, "after_rollback", _release)
event.listen(sess, "after_soft_rollback", _release)
try:
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
entry = _build_entry(prev_hash, actor, action, proposal_id, detail, result)
sess.add(entry)
return entry
except Exception:
_release()
raise
def _build_entry(
prev_hash: str,
actor: str,
action: str,
proposal_id: int | None,
detail: dict | None,
result: str,
) -> AuditEntry:
ts = utcnow() ts = utcnow()
# epoch int: identical after a sqlite/pg round-trip (tz-aware isoformat is not)
ts_int = int(ts.timestamp()) ts_int = int(ts.timestamp())
detail_json = json.dumps(detail or {}, sort_keys=True, ensure_ascii=False) detail_json = json.dumps(detail or {}, sort_keys=True, ensure_ascii=False)
payload = "|".join( payload = "|".join(
@@ -133,31 +194,40 @@ def audit(
result, result,
] ]
) )
entry = AuditEntry( return AuditEntry(
ts=ts, 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, prev_hash=prev_hash or "GENESIS",
entry_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(), entry_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
) )
sess.add(entry)
return entry
def verify_audit_chain(sess) -> tuple[bool, int | None]: def verify_audit_chain(sess) -> tuple[bool, int | None]:
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None).""" """Re-walk the chain. Returns (ok, first_broken_entry_id or None).
prev_hash = "GENESIS"
Legacy rows (entry_hash IS NULL, written before migration 0002) are
skipped and documented: the chain begins GENESIS at the first hashed row,
so a migrated database verifies clean without any tampering.
"""
prev_hash: str | None = None # None = still in legacy prefix
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all(): for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
if entry.entry_hash is None:
continue # legacy row: outside the chain by documented genesis
if prev_hash is None:
expected_prev = "GENESIS" # first hashed row anchors the chain
else:
expected_prev = prev_hash
detail_json = json.dumps(entry.detail or {}, sort_keys=True, ensure_ascii=False) detail_json = json.dumps(entry.detail or {}, sort_keys=True, ensure_ascii=False)
ts = entry.ts ts = entry.ts
if ts.tzinfo is None: if ts.tzinfo is None:
ts = ts.replace(tzinfo=UTC) ts = ts.replace(tzinfo=UTC)
payload = "|".join( payload = "|".join(
[ [
prev_hash, expected_prev,
str(int(ts.timestamp())), str(int(ts.timestamp())),
entry.actor, entry.actor,
entry.action, entry.action,
@@ -167,7 +237,7 @@ def verify_audit_chain(sess) -> tuple[bool, int | None]:
] ]
) )
expected = hashlib.sha256(payload.encode("utf-8")).hexdigest() expected = hashlib.sha256(payload.encode("utf-8")).hexdigest()
if entry.prev_hash != prev_hash or entry.entry_hash != expected: if entry.prev_hash != expected_prev or entry.entry_hash != expected:
return False, entry.id return False, entry.id
prev_hash = entry.entry_hash prev_hash = entry.entry_hash
return True, None return True, None
@@ -222,6 +292,7 @@ class MailMessage(Base):
# binding: hash of the draft text the human approved + who approved it # binding: hash of the draft text the human approved + who approved it
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True) approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
operator_signature: Mapped[str | None] = mapped_column(Text, 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)
@@ -254,6 +325,7 @@ class ContentItem(Base):
attempts: Mapped[int] = mapped_column(Integer, default=0) attempts: Mapped[int] = mapped_column(Integer, default=0)
# binding: hash of the caption the human approved # binding: hash of the caption the human approved
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+103
View File
@@ -0,0 +1,103 @@
"""Operator approval signing — mandatory, content- and deployment-bound.
Every human approval (executor approve / maild approve-draft / publisher
approve) must carry a BIP-340 signature made with the operator key:
message = sha256("cayce-ops:<type>:<deployment_id>:<id>:<content_hash>")
where content_hash binds the approval to the exact approved content:
proposal: canonical json of {kind, title, url, detail}
mail: canonical json of {to_addr, subject, body}
content: canonical json of {channel, caption, link}
There is NO degraded mode: without CAYCE_OPS_OPERATOR_PUBKEY and
CAYCE_OPS_DEPLOYMENT_ID the daemons refuse to execute/send/publish
(see cayceops.config.Config.signing_credentials).
"""
from __future__ import annotations
import hashlib
import json
class SignatureError(RuntimeError):
"""Raised when an approval signature is missing, invalid, or foreign."""
def _canonical(obj: dict) -> str:
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def _sha256_hex(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def proposal_content_hash(kind: str, title: str, url: str | None, detail: dict | None) -> str:
return _sha256_hex(
_canonical(
{
"kind": kind,
"title": title,
"url": url,
"detail": detail or {},
}
)
)
def mail_content_hash(to_addr: str | None, subject: str | None, body: str | None) -> str:
return _sha256_hex(
_canonical(
{
"to_addr": to_addr,
"subject": subject,
"body": body,
}
)
)
def content_content_hash(channel: str, caption: str, link: str | None) -> str:
return _sha256_hex(
_canonical(
{
"channel": channel,
"caption": caption,
"link": link,
}
)
)
def approval_message(
item_type: str, deployment_id: str, item_id: int, content_hash: str
) -> bytes:
"""The exact 32-byte message the operator signs."""
raw = f"cayce-ops:{item_type}:{deployment_id}:{item_id}:{content_hash}"
return hashlib.sha256(raw.encode("utf-8")).digest()
def verify_signature(
item_type: str,
deployment_id: str,
item_id: int,
content_hash: str,
signature_hex: str | None,
operator_pubkey_hex: str,
) -> bool:
"""Verify an operator approval signature. Foreign deployments or edited
content verify False because both are inside the signed message."""
if not signature_hex or not operator_pubkey_hex or not deployment_id:
return False
from cayceops.components.publisher.nostr import _schnorr_verify
try:
return _schnorr_verify(
bytes.fromhex(signature_hex),
approval_message(item_type, deployment_id, item_id, content_hash),
bytes.fromhex(operator_pubkey_hex),
)
except (ValueError, TypeError):
return False
+65
View File
@@ -10,8 +10,14 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
import pytest import pytest
from coincurve import PublicKey
# test operator keypair (invented; allowlisted in .gitleaks.toml)
OPERATOR_SECRET = bytes.fromhex("22" * 32)
OPERATOR_PUBKEY = PublicKey.from_secret(OPERATOR_SECRET).format().hex()[2:]
# Config must see the test DB before cayceops.config/db get imported. # Config must see the test DB before cayceops.config/db get imported.
os.environ.setdefault("CAYCE_OPS_DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("CAYCE_OPS_DATABASE_URL", "sqlite:///:memory:")
@@ -54,6 +60,61 @@ def sess(db_engine):
s.close() s.close()
@pytest.fixture()
def signer():
"""Signs approval messages with the test operator key (22*32, deployment
'itest'). Mirrors exactly what the operator CLI does."""
from coincurve import PrivateKey
from cayceops.models import MailDirection
from cayceops.signing import (
approval_message,
content_content_hash,
mail_content_hash,
proposal_content_hash,
)
secret = OPERATOR_SECRET
def _sig(msg: bytes) -> str:
return PrivateKey(secret).sign_schnorr(msg, aux_randomness=b"\x00" * 32).hex()
def proposal(p, deployment="itest"):
chash = proposal_content_hash(p.kind, p.title, p.url, p.detail)
return _sig(approval_message("proposal", deployment, p.id, chash))
def mail(record, deployment="itest"):
to = (
record.from_addr
if record.direction == MailDirection.INBOUND
else record.to_addr
)
chash = mail_content_hash(to, record.subject, record.draft_reply)
return _sig(approval_message("mail", deployment, record.id, chash))
def content(item, deployment="itest"):
chash = content_content_hash(item.channel, item.caption, item.link)
return _sig(approval_message("content", deployment, item.id, chash))
def raw(sig_bytes: bytes) -> str:
return _sig(sig_bytes)
def msg(item_type: str, deployment: str, item_id: int, chash: str) -> bytes:
from cayceops.signing import approval_message
return approval_message(item_type, deployment, item_id, chash)
def content_hash(item, deployment="itest") -> str:
from cayceops.signing import content_content_hash
return content_content_hash(item.channel, item.caption, item.link)
return SimpleNamespace(
proposal=proposal, mail=mail, content=content, raw=raw, msg=msg,
content_hash=content_hash,
)
@pytest.fixture() @pytest.fixture()
def cfg(monkeypatch): def cfg(monkeypatch):
"""A Config snapshot with test defaults (no network, no autonomy).""" """A Config snapshot with test defaults (no network, no autonomy)."""
@@ -66,6 +127,10 @@ def cfg(monkeypatch):
"CAYCE_OPS_ENABLE_INSTAGRAM": "false", "CAYCE_OPS_ENABLE_INSTAGRAM": "false",
"CAYCE_OPS_NOSTR_RELAYS": "", "CAYCE_OPS_NOSTR_RELAYS": "",
"CAYCE_OPS_MEDIA_DIR": "media", "CAYCE_OPS_MEDIA_DIR": "media",
# signing defaults (audit round 2): tests operate in a signed
# deployment unless they explicitly clear these
"CAYCE_OPS_DEPLOYMENT_ID": "itest",
"CAYCE_OPS_OPERATOR_PUBKEY": OPERATOR_PUBKEY,
} }
def _apply(**overrides): def _apply(**overrides):
+141 -86
View File
@@ -1,4 +1,9 @@
"""executor tests — human gate, autonomy bounds, retries, audit chain.""" """executor tests — human gate (signed), autonomy bounds, retries, audit chain.
Since audit round 2, approvals REQUIRE an operator signature bound to the
proposal's current content + deployment id (tests sign with the conftest
operator key 22*32, deployment 'itest').
"""
from __future__ import annotations from __future__ import annotations
@@ -12,16 +17,22 @@ def _proposal(kind="content_gap", title="Test item", **kw):
return Proposal(kind=kind, title=title, **kw) return Proposal(kind=kind, title=title, **kw)
def test_approve_requires_proposed_state(cfg, sess): def test_approve_requires_proposed_state(cfg, sess, signer):
p = _proposal() p = _proposal()
sess.add(p) sess.add(p)
sess.flush() sess.flush()
assert executor.approve(p.id, "gustavo", cfg(), sess) == 0 assert (
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
)
# second approve must fail (state is now approved) # second approve must fail (state is now approved)
assert executor.approve(p.id, "gustavo", cfg(), sess) == 1 assert (
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 1
)
assert p.approved_by == "gustavo" assert p.approved_by == "gustavo"
assert p.status == ProposalStatus.APPROVED assert p.status is ProposalStatus.APPROVED
# FULL signature stored on the row
assert p.operator_signature and len(p.operator_signature) == 128
def test_reject_records_actor(cfg, sess): def test_reject_records_actor(cfg, sess):
@@ -29,75 +40,26 @@ def test_reject_records_actor(cfg, sess):
sess.add(p) sess.add(p)
sess.flush() sess.flush()
assert executor.reject(p.id, "gustavo", cfg(), sess) == 0 assert executor.reject(p.id, "gustavo", cfg(), sess) == 0
assert p.status == ProposalStatus.REJECTED assert p.status is ProposalStatus.REJECTED
def test_signed_approval_required_when_operator_key_configured(cfg, sess): def test_run_executes_only_approved(cfg, sess, signer):
"""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):
approved = _proposal(title="Approved item") approved = _proposal(title="Approved item")
proposed = _proposal(title="Proposed item") proposed = _proposal(title="Proposed item")
sess.add_all([approved, proposed]) sess.add_all([approved, proposed])
sess.flush() sess.flush()
executor.approve(approved.id, "gustavo", cfg(), sess) assert (
executor.approve(
approved.id, "gustavo", cfg(), sess, signature=signer.proposal(approved)
)
== 0
)
config = cfg() # AUTONOMOUS_MODE=false config = cfg() # AUTONOMOUS_MODE=false
assert executor.run(config, sess) == 0 assert executor.run(config, sess) == 0
assert approved.status == ProposalStatus.DONE assert approved.status is ProposalStatus.DONE
assert proposed.status == ProposalStatus.PROPOSED # untouched assert proposed.status is ProposalStatus.PROPOSED # untouched
acts = [a.action for a in sess.query(AuditEntry).order_by(AuditEntry.id).all()] acts = [a.action for a in sess.query(AuditEntry).order_by(AuditEntry.id).all()]
assert "execute_start" in acts and "execute_done" in acts assert "execute_start" in acts and "execute_done" in acts
@@ -110,36 +72,127 @@ def test_autonomy_off_by_default(cfg, sess):
sess.flush() sess.flush()
config = cfg() # autonomous_mode False even with kinds configured config = cfg() # autonomous_mode False even with kinds configured
executor._auto_approve(config, sess) executor._auto_approve(config, sess)
assert p.status == ProposalStatus.PROPOSED assert p.status is ProposalStatus.PROPOSED
def test_autonomy_respects_kinds_and_daily_cap(cfg, sess): def test_autonomy_cannot_approve_anymore(cfg, sess):
p1 = _proposal(kind="content_gap", title="auto ok") """Round-2 1a: with mandatory signatures there is NO non-human approval
p2 = _proposal(kind="content_gap", title="auto over cap") path. AUTONOMOUS_MODE no longer approves anything (D13)."""
p3 = _proposal(kind="affiliate", title="kind not allowed") low = _proposal(kind="content_gap", title="barely relevant")
sess.add_all([p1, p2, p3]) 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() sess.flush()
config = cfg( config = cfg(
CAYCE_OPS_AUTONOMOUS_MODE="true", CAYCE_OPS_AUTONOMOUS_MODE="true",
CAYCE_OPS_AUTONOMOUS_KINDS="content_gap", CAYCE_OPS_AUTONOMOUS_KINDS="content_gap",
CAYCE_OPS_AUTONOMOUS_DAILY_CAP="1", CAYCE_OPS_AUTONOMOUS_DAILY_CAP="1",
) )
approved = executor._auto_approve(config, sess) assert executor._auto_approve(config, sess) == 0
assert approved == 1 assert low.status is ProposalStatus.PROPOSED
assert p1.status == ProposalStatus.APPROVED assert high.status is ProposalStatus.PROPOSED
assert p2.status == ProposalStatus.PROPOSED # cap reached entry = sess.query(AuditEntry).filter_by(action="autonomy_disabled").one()
assert p3.status == ProposalStatus.PROPOSED # kind not in policy assert entry.result == "error"
actor = sess.query(AuditEntry).filter_by(action="approve").one().actor
assert actor == "auto:policy"
def test_failing_plugin_retries_then_fails(cfg, sess, monkeypatch): def test_signed_approval_mandatory_and_content_bound(cfg, sess, signer):
"""Round-2 1a/1b/1c: signature mandatory, bound to content+deployment,
stored COMPLETE on the row and in the audit log. Signatures for other
deployments or other content are refused."""
from cayceops.signing import proposal_content_hash
p = _proposal(kind="partnership", title="Acme partner", url="https://acme.example.org")
p.detail = {"region": "latam"}
sess.add(p)
sess.flush()
chash = proposal_content_hash(p.kind, p.title, p.url, p.detail)
# no signature -> refused
assert executor.approve(p.id, "gustavo", cfg(), sess, signature=None) == 2
# signature for ANOTHER deployment -> refused (required test)
other_dep = signer.raw(
signer.msg("proposal", "other-deploy", p.id, chash)
)
assert executor.approve(p.id, "gustavo", cfg(), sess, signature=other_dep) == 2
# signature over OTHER CONTENT -> refused (required test)
other_content = signer.raw(signer.msg("proposal", "itest", p.id, "00" * 32))
assert executor.approve(p.id, "gustavo", cfg(), sess, signature=other_content) == 2
# valid signature -> approved
assert (
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
)
assert p.status is ProposalStatus.APPROVED
entry = sess.query(AuditEntry).filter_by(action="approve", result="ok").one()
assert entry.detail["signature"] == p.operator_signature
def test_approve_fails_audited_from_cli_wrapper(cfg, sess):
"""Round-2 1f: signature failures leave an audit record even when the
command runs through its CLI wrapper (the wrapper passes sess)."""
p = _proposal()
sess.add(p)
sess.flush()
code = executor.approve(p.id, "gustavo", cfg(), sess, signature=None)
assert code == 2
entry = sess.query(AuditEntry).filter_by(action="approve", result="error").one()
assert "signature required" in entry.detail["reason"]
def test_sql_approved_row_is_refused_at_execution(cfg, sess, monkeypatch):
"""Round-2 1d (required test): a row put in `approved` by raw SQL without
a signature must not execute; it reverts to `proposed` with an error audit."""
p = _proposal()
p.status = ProposalStatus.APPROVED # raw-SQL style: no signature
sess.add(p)
sess.flush()
executed = []
def spy_plugin(proposal, cfg, sess):
executed.append(proposal.id)
return {}
monkeypatch.setitem(plugins.REGISTRY, "content_gap", spy_plugin)
assert executor.run(cfg(), sess) == 0
assert executed == []
assert p.status is ProposalStatus.PROPOSED # reverted
refused = sess.query(AuditEntry).filter_by(action="execute_refused").one()
assert refused.result == "error"
def test_execution_verifies_signature_against_current_content(cfg, sess, signer, monkeypatch):
"""Round-2 1d: signature over content that changed after approval is
refused at execution time."""
p = _proposal(kind="partnership", title="original")
sess.add(p)
sess.flush()
assert (
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
)
p.title = "EDITED after approval" # content changed under the signature
executed = []
monkeypatch.setitem(
plugins.REGISTRY,
"partnership",
lambda proposal, cfg, sess: executed.append(proposal.id) or {},
)
assert executor.run(cfg(), sess) == 0
assert executed == []
assert p.status is ProposalStatus.PROPOSED
def test_failing_plugin_retries_then_fails(cfg, sess, signer, monkeypatch):
p = _proposal(kind="affiliate", title="Will fail") p = _proposal(kind="affiliate", title="Will fail")
sess.add(p) sess.add(p)
sess.flush() sess.flush()
executor.approve(p.id, "gustavo", cfg(), sess) assert (
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
)
calls = {"n": 0} calls = {"n": 0}
@@ -151,11 +204,11 @@ def test_failing_plugin_retries_then_fails(cfg, sess, monkeypatch):
config = cfg(CAYCE_OPS_MAX_ATTEMPTS="2") config = cfg(CAYCE_OPS_MAX_ATTEMPTS="2")
executor.run(config, sess) # attempt 1 -> back to approved for retry executor.run(config, sess) # attempt 1 -> back to approved for retry
assert p.status == ProposalStatus.APPROVED assert p.status is ProposalStatus.APPROVED
assert p.attempts == 1 assert p.attempts == 1
executor.run(config, sess) # attempt 2 -> failed for good executor.run(config, sess) # attempt 2 -> failed for good
assert p.status == ProposalStatus.FAILED assert p.status is ProposalStatus.FAILED
assert p.attempts == 2 assert p.attempts == 2
assert calls["n"] == 2 assert calls["n"] == 2
@@ -174,14 +227,16 @@ def test_http_post_blocked_when_disabled(cfg, sess):
plugins.http_post(p, config, sess) plugins.http_post(p, config, sess)
def test_unknown_kind_falls_back_to_log_action(cfg, sess): def test_unknown_kind_falls_back_to_log_action(cfg, sess, signer):
p = _proposal(kind="brand_new_kind", title="No plugin yet") p = _proposal(kind="brand_new_kind", title="No plugin yet")
sess.add(p) sess.add(p)
sess.flush() sess.flush()
executor.approve(p.id, "gustavo", cfg(), sess) assert (
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
)
assert executor.run(cfg(), sess) == 0 assert executor.run(cfg(), sess) == 0
assert p.status == ProposalStatus.DONE assert p.status is ProposalStatus.DONE
done = sess.query(AuditEntry).filter_by(action="execute_done").one() done = sess.query(AuditEntry).filter_by(action="execute_done").one()
assert done.detail["outcome"]["plugin_fallback"] is True assert done.detail["outcome"]["plugin_fallback"] is True
+195 -16
View File
@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace import os
from coincurve import PrivateKey
from coincurve import PublicKey as _PK
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
@@ -16,9 +19,20 @@ from cayceops.models import (
ProposalStatus, ProposalStatus,
) )
# test operator keypair (matches conftest default pubkey; invented)
OPERATOR_SECRET = bytes.fromhex("22" * 32)
OPERATOR_PUBKEY = _PK.from_secret(OPERATOR_SECRET).format().hex()[2:]
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") # the signing gate reads env at runtime — make sure every path in this module
# (fixtures included) sees a signed deployment
os.environ.setdefault("CAYCE_OPS_DEPLOYMENT_ID", "itest")
os.environ.setdefault("CAYCE_OPS_OPERATOR_PUBKEY", OPERATOR_PUBKEY)
from cayceops.config import load as _load # noqa: E402 (env set above)
STUB_CFG = _load() # a real, signed Config (replaces the old SimpleNamespace)
def _eml(subject, body, in_reply_to=None, msgid=None) -> bytes: def _eml(subject, body, in_reply_to=None, msgid=None) -> bytes:
@@ -34,9 +48,24 @@ def _eml(subject, body, in_reply_to=None, msgid=None) -> bytes:
return "\r\n".join(lines).encode() return "\r\n".join(lines).encode()
def _sign(msg: bytes, secret: bytes = OPERATOR_SECRET) -> str:
return PrivateKey(secret).sign_schnorr(msg, aux_randomness=b"\x00" * 32).hex()
def _approve_signed(sess, mail_id: int) -> str:
"""Approve with a valid operator signature over the row's current content."""
from cayceops.signing import approval_message, mail_content_hash
record = sess.get(MailMessage, mail_id)
to_addr = record.from_addr if record.direction == MailDirection.INBOUND else record.to_addr
chash = mail_content_hash(to_addr, record.subject, record.draft_reply)
return _sign(approval_message("mail", "itest", mail_id, chash))
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: """A proposal whose first-contact mail went through the REAL path:
compose -> approve -> send (creating a true OUTBOUND row).""" compose -> approve (signed) -> send (creating a true OUTBOUND row)."""
p = Proposal( p = Proposal(
kind="partnership", kind="partnership",
title="Acme hotel affiliate program", title="Acme hotel affiliate program",
@@ -47,7 +76,10 @@ def _proposal_with_sent_draft(sess) -> Proposal:
mail_id = maild._compose( mail_id = maild._compose(
p.id, "partners@acme.example.org", "[cayce] partnership inquiry", "Hello!", sess p.id, "partners@acme.example.org", "[cayce] partnership inquiry", "Hello!", sess
) )
assert maild._approve_draft(mail_id, "gustavo", sess) == 0 assert (
maild._approve_draft(mail_id, "gustavo", sess, signature=_approve_signed(sess, mail_id))
== 0
)
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent" assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
out = ( out = (
sess.query(MailMessage) sess.query(MailMessage)
@@ -72,8 +104,15 @@ def test_rules_classification(cfg):
clf.classify("Your application was approved, welcome to the program", config)[0] clf.classify("Your application was approved, welcome to the program", config)[0]
is MailClassification.APPROVAL is MailClassification.APPROVAL
) )
# negation present -> approval is vetoed to needs_review (round-2 rule;
# the human decides between rejection and other)
assert ( assert (
clf.classify("Unfortunately we cannot accept new partners", config)[0] clf.classify("Unfortunately we cannot accept new partners", config)[0]
is MailClassification.NEEDS_REVIEW
)
# plain rejection (no approval word involved) still classifies directly
assert (
clf.classify("We regret to inform you that your application was declined", config)[0]
is MailClassification.REJECTION is MailClassification.REJECTION
) )
assert ( assert (
@@ -84,10 +123,16 @@ def test_rules_classification(cfg):
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): def test_negated_approvals_never_classify_approval(cfg):
"""Critical finding 4: 'cannot accept'/'not approved' read as approvals.""" """Round-2 point 2: any negation vetoes `approval` -> needs_review."""
config = cfg() config = cfg()
cases = [ cases = [
"We cannot currently accept new affiliates",
"We will never accept crypto businesses",
"Approval was not granted for your site",
"We are no longer accepting applications",
"Your application hasn't been approved",
"We have decided not to move forward with your application",
"We cannot accept your application", "We cannot accept your application",
"We are not accepting new partners at this time", "We are not accepting new partners at this time",
"Your application was not approved", "Your application was not approved",
@@ -95,9 +140,8 @@ def test_negated_approvals_are_rejections(cfg):
"We do not accept external affiliates", "We do not accept external affiliates",
] ]
for text in cases: for text in cases:
kind, confidence = clf.classify(text, config) kind, _ = clf.classify(text, config)
assert kind is MailClassification.REJECTION, f"{text!r} -> {kind}" assert kind is not MailClassification.APPROVAL, f"{text!r} -> {kind}"
assert confidence >= 0.9
def test_llm_prompt_format_survives_json_braces(): def test_llm_prompt_format_survives_json_braces():
@@ -197,7 +241,12 @@ def test_send_requires_explicit_human_approval(cfg, sess):
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 original_id = inbound.message_id
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0 assert (
maild._approve_draft(
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
)
== 0
)
sent = [] sent = []
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz") config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
@@ -237,7 +286,12 @@ def test_double_approve_is_rejected(cfg, sess):
maild._poll(cfg(), sess, fetcher=_fetcher([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 maild._approve_draft(inbound.id, "gustavo", sess) == 0 assert (
maild._approve_draft(
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
)
== 0
)
assert maild._approve_draft(inbound.id, "gustavo", sess) == 1 assert maild._approve_draft(inbound.id, "gustavo", sess) == 1
@@ -256,7 +310,10 @@ def test_send_failure_does_not_resurrect_sent_mails(cfg, sess):
mail_id = maild._compose( mail_id = maild._compose(
p.id, f"desk{i}@acme.example.org", f"[cayce] inquiry {i}", "Hello!", sess 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._approve_draft(mail_id, "gustavo", sess, signature=_approve_signed(sess, mail_id))
== 0
)
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent" assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
sess.flush() sess.flush()
out = sess.get(MailMessage, mail_id) out = sess.get(MailMessage, mail_id)
@@ -272,7 +329,12 @@ def test_send_failure_does_not_resurrect_sent_mails(cfg, sess):
.filter_by(direction=MailDirection.INBOUND, message_id=f"<acme-{i}@acme.example.org>") .filter_by(direction=MailDirection.INBOUND, message_id=f"<acme-{i}@acme.example.org>")
.one() .one()
) )
maild._approve_draft(inbound.id, "gustavo", sess) assert (
maild._approve_draft(
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
)
== 0
)
sess.commit() # per-message factory sessions only see committed rows sess.commit() # per-message factory sessions only see committed rows
calls = {"n": 0} calls = {"n": 0}
@@ -304,7 +366,12 @@ def test_edited_draft_refuses_to_send(cfg, 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=_fetcher([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 maild._approve_draft(inbound.id, "gustavo", sess) == 0 assert (
maild._approve_draft(
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
)
== 0
)
inbound.draft_reply = "TOTALLY EDITED TEXT after approval" # tamper inbound.draft_reply = "TOTALLY EDITED TEXT after approval" # tamper
@@ -314,7 +381,7 @@ def test_edited_draft_refuses_to_send(cfg, sess):
assert stats["sent"] == 0 and stats["hash_mismatch"] == 1 assert stats["sent"] == 0 and stats["hash_mismatch"] == 1
assert sent == [] assert sent == []
assert inbound.draft_status is DraftStatus.DRAFTED # needs fresh approval assert inbound.draft_status is DraftStatus.DRAFTED # needs fresh approval
action = sess.query(AuditEntry).filter_by(action="send_refused_hash_mismatch").one() action = sess.query(AuditEntry).filter_by(action="send_refused").one()
assert action.result == "error" assert action.result == "error"
@@ -334,7 +401,10 @@ def test_compose_creates_first_contact_draft(cfg, sess):
assert out.proposal_id == p.id assert out.proposal_id == p.id
# approve + send -> an inbound reply can now thread to this proposal # approve + send -> an inbound reply can now thread to this proposal
assert maild._approve_draft(mail_id, "gustavo", sess) == 0 assert (
maild._approve_draft(mail_id, "gustavo", sess, signature=_approve_signed(sess, mail_id))
== 0
)
stats = maild._send_approved(STUB_CFG, sess, sender=lambda *a, **k: None) stats = maild._send_approved(STUB_CFG, sess, sender=lambda *a, **k: None)
assert stats["sent"] == 1 assert stats["sent"] == 1
sess.flush() sess.flush()
@@ -349,3 +419,112 @@ def test_mark_seen_returned_for_post_commit_processing(cfg, sess):
stats, nums = maild._poll(cfg(), sess, fetcher=_fetcher([raw])) stats, nums = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
assert stats["processed"] == 1 assert stats["processed"] == 1
assert nums == [b"0"] # caller marks seen only after commit assert nums == [b"0"] # caller marks seen only after commit
# --- round-2 signature gate (mail) ------------------------------------------------
def _approved_reply(sess, cfg) -> MailMessage:
_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]))
return sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
def test_unsigned_sql_approved_draft_is_refused(cfg, sess):
"""Round-2 required test: raw-SQL approved row (no signature) not sent."""
_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()
# raw-SQL style approval: status flips, no signature
sess.execute(
MailMessage.__table__.update()
.where(MailMessage.id == inbound.id)
.values(draft_status=DraftStatus.APPROVED)
)
sess.expire(inbound)
sent = []
stats = maild._send_approved(STUB_CFG, 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 # reverted
refused = sess.query(AuditEntry).filter_by(action="send_refused").one()
assert refused.result == "error"
def test_changed_to_addr_after_approval_blocks_send(cfg, sess):
"""Round-2 required test: to_addr edited after approval -> refused."""
_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, signature=_approve_signed(sess, inbound.id)
)
== 0
)
inbound.from_addr = "attacker@evil.example.org" # redirect target
sent = []
stats = maild._send_approved(STUB_CFG, 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
def test_foreign_deployment_signature_refused(cfg, sess):
"""Round-2 required test: a valid signature for another deployment_id (or
other content) is refused."""
from cayceops.signing import approval_message, mail_content_hash
_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()
chash = mail_content_hash(inbound.from_addr, inbound.subject, inbound.draft_reply)
# signature valid for ANOTHER deployment
other_dep = _sign(
OPERATOR_SECRET, approval_message("mail", "other-deploy", inbound.id, chash)
)
assert maild._approve_draft(inbound.id, "gustavo", sess, signature=other_dep) == 2
assert inbound.draft_status is DraftStatus.DRAFTED
# signature valid for OTHER CONTENT
other_content = _sign(
OPERATOR_SECRET, approval_message("mail", "itest", inbound.id, "00" * 32)
)
assert maild._approve_draft(inbound.id, "gustavo", sess, signature=other_content) == 2
assert inbound.draft_status is DraftStatus.DRAFTED
def test_send_failures_audited_and_exit_code(cfg, sess, monkeypatch):
"""Round-2 point 5: failures audited + surfaced; non-zero exit handled by CLI."""
import cayceops.db as appdb
_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, signature=_approve_signed(sess, inbound.id)
)
== 0
)
def boom(cfg, to, subject, body, message_id, in_reply_to=None):
raise OSError("relay down")
sess.commit() # per-message factory sessions only see committed rows
stats = maild._send_approved(
STUB_CFG, sess, sender=boom, factory=appdb.session_factory()
)
assert stats["error"] == 1 and stats["sent"] == 0
failed = sess.query(AuditEntry).filter_by(action="send_failed").one()
assert failed.result == "error"
+32
View File
@@ -110,3 +110,35 @@ def test_audit_chain_and_verification(sess):
first.actor = "tampered" first.actor = "tampered"
ok, broken = verify_audit_chain(sess) ok, broken = verify_audit_chain(sess)
assert not ok and broken == first.id assert not ok and broken == first.id
def test_concurrent_writers_keep_chain_verifiable(db_engine):
"""Round-2 point 4: serialized writes -> no forked chains."""
import threading
import cayceops.db as appdb
errors: list[Exception] = []
def writer(worker: int, n: int):
try:
for i in range(n):
with appdb.session_scope() as s:
audit(s, actor=f"w{worker}", action=f"step{i}", detail={"worker": worker})
except Exception as exc: # noqa: BLE001
errors.append(exc)
threads = [threading.Thread(target=writer, args=(w, 25)) for w in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, errors
s = appdb.session_factory()()
total = s.query(AuditEntry).count()
assert total == 100
from cayceops.models import verify_audit_chain
ok, broken = verify_audit_chain(s)
assert ok and broken is None
+106 -67
View File
@@ -1,16 +1,30 @@
"""publisher tests — calendar, human gate, nostr publish, disabled adapters.""" """publisher tests — calendar, human gate (signed), nostr publish, gates."""
from __future__ import annotations from __future__ import annotations
import os from datetime import UTC, datetime, timedelta
import pytest import pytest
from cayceops.components.publisher import adapters, nostr, publisher from cayceops.components.publisher import adapters, nostr, publisher
from cayceops.models import AuditEntry, ContentItem, ContentStatus from cayceops.models import AuditEntry, ContentItem, ContentStatus
# invented test key (never a real nostr key — allowlisted in .gitleaks.toml) # invented test keys (never real — allowlisted in .gitleaks.toml)
TEST_KEY = "11" * 32 TEST_KEY = "11" * 32 # nostr signing key
OPERATOR_SECRET = bytes.fromhex("22" * 32) # approval signing key
def _sign(msg: bytes, secret: bytes = OPERATOR_SECRET) -> str:
from coincurve import PrivateKey
return PrivateKey(secret).sign_schnorr(msg, aux_randomness=b"\x00" * 32).hex()
def _sign_content(item: ContentItem, deployment: str = "itest") -> str:
from cayceops.signing import approval_message, content_content_hash
chash = content_content_hash(item.channel, item.caption, item.link)
return _sign(approval_message("content", deployment, item.id, chash))
def _item(channel="nostr", theme="atm-fees", scheduled=None, status=ContentStatus.APPROVED): def _item(channel="nostr", theme="atm-fees", scheduled=None, status=ContentStatus.APPROVED):
@@ -24,6 +38,16 @@ def _item(channel="nostr", theme="atm-fees", scheduled=None, status=ContentStatu
) )
def _approved_item(cfg, sess, **kw) -> ContentItem:
"""A content item properly approved with a signature (env set first)."""
cfg() # sets the signing env before any approval
item = _item(status=ContentStatus.DRAFT, **kw)
sess.add(item)
sess.flush()
assert publisher._approve(item.id, "gustavo", sess, signature=_sign_content(item)) == 0
return item
# --- nostr client ------------------------------------------------------------- # --- nostr client -------------------------------------------------------------
@@ -65,10 +89,16 @@ def test_approve_gate(cfg, sess):
item = _item(status=ContentStatus.DRAFT) item = _item(status=ContentStatus.DRAFT)
sess.add(item) sess.add(item)
sess.flush() sess.flush()
cfg() # signing env first
assert publisher._approve(item.id, "gustavo", sess) == 0 # unsigned approval refused (mandatory signature, no degraded mode)
assert publisher._approve(item.id, "gustavo", sess, signature=None) == 2
# signed approval accepted, FULL signature stored
sig = _sign_content(item)
assert publisher._approve(item.id, "gustavo", sess, signature=sig) == 0
assert item.status is ContentStatus.APPROVED assert item.status is ContentStatus.APPROVED
assert publisher._approve(item.id, "gustavo", sess) == 1 # not draft anymore assert item.operator_signature == sig and len(sig) == 128
assert publisher._approve(item.id, "gustavo", sess, signature=sig) == 1 # not draft anymore
actor = sess.query(AuditEntry).filter_by(action="approve_content").first().actor actor = sess.query(AuditEntry).filter_by(action="approve_content").first().actor
assert actor == "gustavo" assert actor == "gustavo"
@@ -78,9 +108,7 @@ def test_approve_gate(cfg, sess):
def test_publish_due_nostr_happy_path(cfg, sess): def test_publish_due_nostr_happy_path(cfg, sess):
item = _item() # approved, scheduled None -> due now item = _approved_item(cfg, sess)
sess.add(item)
sess.flush()
relays_seen = [] relays_seen = []
@@ -99,25 +127,69 @@ def test_publish_due_nostr_happy_path(cfg, sess):
assert item.status is ContentStatus.PUBLISHED assert item.status is ContentStatus.PUBLISHED
assert item.post_ref and len(item.post_ref) == 64 assert item.post_ref and len(item.post_ref) == 64
assert item.published_at is not None assert item.published_at is not None
assert "wss://relay.example.org" in relays_seen[0] # noqa: E501 assert "wss://relay.example.org" in relays_seen[0]
def test_future_scheduled_item_not_published(cfg, sess): def test_future_scheduled_item_not_published(cfg, sess):
from datetime import UTC, datetime, timedelta item = _approved_item(
cfg, sess, scheduled=datetime.now(UTC) + timedelta(hours=2)
item = _item(scheduled=datetime.now(UTC) + timedelta(hours=2)) )
sess.add(item) config = cfg(
sess.flush() CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
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: []) stats = publisher._publish_due(
config, sess, nostr_sender=lambda r, e, timeout=10: []
)
assert stats["published"] == 0 assert stats["published"] == 0
assert item.status is ContentStatus.APPROVED assert item.status is ContentStatus.APPROVED
def test_nostr_without_config_fails_clean(cfg, sess): def test_unsigned_sql_approved_item_not_published(cfg, sess):
item = _item() """Round-2 required test: raw-SQL approved row (no signature) not published."""
config = cfg(
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
)
item = _item() # status=APPROVED, no signature
sess.add(item) sess.add(item)
sess.flush() sess.flush()
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.DRAFT # reverted
refused = sess.query(AuditEntry).filter_by(action="publish_refused").one()
assert refused.result == "error"
def test_tampered_caption_refused_by_signature(cfg, sess):
"""Round-2 required test: signature over other content refused at publish."""
item = _approved_item(cfg, sess)
item.caption = item.caption + " EDITED" # content no longer matches sig
nostr_config = cfg(
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
)
stats = publisher._publish_due(
nostr_config, sess, nostr_sender=lambda r, e, timeout=10: []
)
assert stats["published"] == 0 and stats["tampered"] == 1
assert item.status is ContentStatus.DRAFT
def test_foreign_deployment_signature_refused(cfg, sess, signer):
"""Round-2 required test: a valid signature for another deployment_id is
refused at approval time."""
item = _item(status=ContentStatus.DRAFT)
sess.add(item)
sess.flush()
other_dep = signer.raw(
signer.msg("content", "other-deploy", item.id, signer.content_hash(item))
)
assert publisher._approve(item.id, "gustavo", sess, signature=other_dep) == 2
assert item.status is ContentStatus.DRAFT
def test_nostr_without_config_fails_clean(cfg, sess):
"""Signed item but no nostr credentials -> clean failure, item stays."""
item = _approved_item(cfg, sess)
config = cfg() # no key, no relays config = cfg() # no key, no relays
stats = publisher._publish_due(config, sess) stats = publisher._publish_due(config, sess)
assert stats["failed"] == 1 assert stats["failed"] == 1
@@ -126,14 +198,15 @@ def test_nostr_without_config_fails_clean(cfg, sess):
def test_non_nostr_channels_skipped_terminal(cfg, sess): def test_non_nostr_channels_skipped_terminal(cfg, sess):
item = _item(channel="facebook", theme="lightning-abroad") item = _approved_item(cfg, sess, channel="facebook", theme="lightning-abroad")
sess.add(item)
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.SKIPPED # terminal (audit fix) assert item.status is ContentStatus.SKIPPED # terminal (audit fix)
skip = sess.query(AuditEntry).filter_by(action="publish_skipped_terminal").one()
assert skip.detail["channel"] == "facebook" stats2 = publisher._publish_due(cfg(), sess)
assert stats2["skipped"] == 0 # never revisited
skips = sess.query(AuditEntry).filter_by(action="publish_skipped_terminal").count()
assert skips == 1
def test_meta_adapters_disabled_by_default(cfg): def test_meta_adapters_disabled_by_default(cfg):
@@ -164,61 +237,27 @@ def test_unknown_brand_rejected():
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 ---------------------------------------------------------- # --- round-2: attempts budget --------------------------------------------------
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): def test_publish_attempts_bounded(cfg, sess, monkeypatch):
"""Medium finding: nostr failures retried without limit.""" """Medium finding: nostr failures retried without limit."""
from cayceops.components.publisher import nostr as nostr_mod 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( config = cfg(
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org", CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org",
CAYCE_OPS_MAX_ATTEMPTS="1", CAYCE_OPS_MAX_ATTEMPTS="1",
) )
item = _approved_item(cfg, sess)
def always_fail(relays, event, timeout=10.0):
raise RuntimeError("relay down")
monkeypatch.setattr(nostr_mod, "publish", always_fail)
publisher._publish_due(config, sess, nostr_sender=always_fail) publisher._publish_due(config, sess, nostr_sender=always_fail)
assert item.attempts == 1 assert item.attempts == 1
assert item.status is ContentStatus.SKIPPED # budget exhausted -> terminal assert item.status is ContentStatus.SKIPPED # budget exhausted -> terminal
import os # noqa: E402 (used by image test)