Merge PR #10: audit round 2 — signed approvals, chain serialization, roles, anchoring
ci / lint-test-leaks (push) Canceled after 0s

One commit per point (audit-1..audit-7). Gate at merge: ruff 0 ·
65 unit tests + 1 PG integration test · alembic 0001→0003 with PG
round-trip · gitleaks clean.
This commit is contained in:
2026-10-20 02:21:58 +00:00
24 changed files with 1929 additions and 322 deletions
+31 -3
View File
@@ -8,6 +8,21 @@ on:
jobs:
lint-test-leaks:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: cayce
POSTGRES_PASSWORD: ci-only-not-a-secret
POSTGRES_DB: cayce_ops
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U cayce -d cayce_ops"
--health-interval 5s --health-timeout 3s --health-retries 10
env:
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops
PGPASSWORD: ci-only-not-a-secret
steps:
- uses: actions/checkout@v4
with:
@@ -23,11 +38,24 @@ jobs:
- name: Lint (ruff)
run: uv run ruff check .
- name: Migrations smoke test (SQLite)
- name: Migrations upgrade/downgrade round-trip (Postgres 16)
run: |
uv run alembic upgrade head
uv run alembic downgrade 0001
uv run alembic upgrade head
uv run alembic downgrade base
uv run alembic upgrade head
- name: Alembic smoke (SQLite)
run: uv run alembic upgrade head
- name: Tests (pytest)
run: uv run pytest
- name: Unit tests (pytest)
run: uv run pytest -m "not integration"
- name: Integration tests — roles.sql + daemon cycles (Postgres 16)
run: |
sudo apt-get update && sudo apt-get install -y postgresql-client
uv run pytest -m integration -v
- name: Secret scan (gitleaks, full history)
run: |
+31 -3
View File
@@ -8,6 +8,21 @@ on:
jobs:
lint-test-leaks:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: cayce
POSTGRES_PASSWORD: ci-only-not-a-secret
POSTGRES_DB: cayce_ops
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U cayce -d cayce_ops"
--health-interval 5s --health-timeout 3s --health-retries 10
env:
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops
PGPASSWORD: ci-only-not-a-secret
steps:
- uses: actions/checkout@v4
with:
@@ -23,11 +38,24 @@ jobs:
- name: Lint (ruff)
run: uv run ruff check .
- name: Migrations smoke test (SQLite)
- name: Migrations upgrade/downgrade round-trip (Postgres 16)
run: |
uv run alembic upgrade head
uv run alembic downgrade 0001
uv run alembic upgrade head
uv run alembic downgrade base
uv run alembic upgrade head
- name: Alembic smoke (SQLite)
run: uv run alembic upgrade head
- name: Tests (pytest)
run: uv run pytest
- name: Unit tests (pytest)
run: uv run pytest -m "not integration"
- name: Integration tests — roles.sql + daemon cycles (Postgres 16)
run: |
sudo apt-get update && sudo apt-get install -y postgresql-client
uv run pytest -m integration -v
- name: Secret scan (gitleaks, full history)
run: |
@@ -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,
actor: str = typer.Option(..., help="Human approver id"),
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:
"""Approve a proposal (human action, audited)."""
"""Approve a proposal (human action, audited; signature mandatory)."""
raise typer.Exit(
_load_component("executor").approve(proposal_id, actor, signature=signature)
)
@@ -101,9 +101,17 @@ def maild_poll() -> None:
@maild_app.command("approve-draft")
def maild_approve(msg_id: int, actor: str = typer.Option(...)) -> None:
"""Approve a drafted reply so SMTP may send it."""
raise typer.Exit(_load_component("maild").approve_draft(msg_id, actor))
def maild_approve(
msg_id: int,
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")
@@ -153,9 +161,17 @@ def publisher_image(item_id: int) -> None:
@publisher_app.command("approve")
def publisher_approve(item_id: int, actor: str = typer.Option(...)) -> None:
"""Approve a draft content item."""
raise typer.Exit(_load_component("publisher").approve(item_id, actor))
def publisher_approve(
item_id: int,
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")
@@ -187,6 +203,12 @@ def monitor_run() -> None:
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
app()
+133 -30
View File
@@ -37,11 +37,19 @@ def _load_cfg():
# --- human transitions -------------------------------------------------------
# message signed by the operator key when CAYCE_OPS_OPERATOR_PUBKEY is set
def approval_message(proposal_id: int) -> bytes:
import hashlib
GATE_ERROR = 2 # missing/invalid configuration or signature
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(
@@ -51,36 +59,54 @@ def approve(
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
"""Human approves a proposal. Signature is MANDATORY and bound to the
proposal's current content and this deployment (audit finding 1)."""
from cayceops.config import ConfigError
from cayceops.signing import (
SignatureError,
proposal_content_hash,
verify_signature,
)
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:
try:
deployment_id, pubkey = _signing(cfg)
except ConfigError as exc:
if 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"
)
},
detail={"reason": str(exc)},
)
if not ok:
return 2
return GATE_ERROR
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)
@@ -122,8 +148,8 @@ def _decide(
if decision == ProposalStatus.APPROVED:
proposal.approved_by = actor
proposal.approved_at = datetime.now(UTC)
if signature:
detail["signature"] = signature[:32] + "..."
proposal.operator_signature = signature # FULL signature, row + audit
detail["signature"] = signature or ""
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
audit(sess, actor=actor, action=verb, proposal_id=proposal_id, detail=detail)
return 0
@@ -136,9 +162,20 @@ def _decide(
def _auto_approve(cfg: Config, sess) -> int:
"""Auto-approve proposed items per policy. Returns count approved."""
if not cfg.autonomous_mode or not cfg.autonomous_kinds:
return 0
"""DISABLED (audit round 2, point 1a): approvals require operator
signatures bound to content — there is no non-human approval path.
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)
used = (
sess.query(func.count(AuditEntry.id))
@@ -188,8 +225,67 @@ def _auto_approve(cfg: Config, sess) -> int:
# --- 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:
"""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.attempts += 1
audit(
@@ -256,6 +352,13 @@ def run(cfg: Config | None = None, sess=None) -> int:
run_row = DaemonRun(daemon="executor")
sess.add(run_row)
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:
auto = _auto_approve(cfg, sess)
pending = (
+26 -23
View File
@@ -1,8 +1,12 @@
"""maild classification: rules first, LLM optional, conservative threshold.
"""maild classification: rules + sentence-level negation, LLM optional.
The LLM never decides alone: any result below ``CAYCE_OPS_LLM_REVIEW_THRESHOLD``
confidence becomes ``needs_review``. With the ``null`` provider (default) only
deterministic rules run.
Approval rule (audit round 2, point 2): a mail may only classify `approval`
when NO sentence contains a negation. Any negation token anywhere in the mail
(conservative: even outside the approval sentence) downgrades an approval to
`needs_review`. Rejections/questions/other are unaffected.
The LLM never decides alone: below-threshold confidence → `needs_review`.
With the `null` provider (default) only deterministic rules run.
"""
from __future__ import annotations
@@ -13,25 +17,16 @@ import re
from cayceops.config import Config
from cayceops.models import MailClassification
# NEGATED approval forms are rejections and must be checked FIRST:
# "We cannot accept your application", "not accepting new partners",
# "Your application was not approved" used to match the bare approval
# keywords (accept/approv) and read as approvals — audit finding 4.
_NEGATED_APPROVAL = (
MailClassification.REJECTION,
re.compile(
r"\b(cannot|can.?t|will not|won.?t|do not|don.?t|does not|doesn.?t)\s+"
r"(be\s+able\s+to\s+)?(accept|approve|proceed|offer|welcome)"
r"|\bnot\s+(been\s+)?approved\b"
r"|\bnot\s+accepting\b"
r"|\bunfortunately\b",
re.I,
),
0.95,
_APPROVAL_WORDS = re.compile(r"\b(approv|accept|welcome)", re.I)
# any of these tokens anywhere in the mail vetoes an `approval` result
_NEGATIONS = re.compile(
r"\b(not|no|never|cannot|can'?t|won'?t|hasn'?t|isn'?t|"
r"no\s+longer|unable|decline\w*|regret\w*|unfortunately)\b",
re.I,
)
_RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
_NEGATED_APPROVAL,
# (class, pattern, confidence when matched)
(
MailClassification.REJECTION,
@@ -51,9 +46,6 @@ _RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
),
]
# braces are DOUBLED so .format() does not die on the literal JSON sample
# (the old prompt raised KeyError on every call and the generic except
# silently degraded to rules — audit finding 4)
_LLM_PROMPT = """Classify this merchant email into exactly one of:
approval, rejection, question, other.
Reply with strict JSON: {{"classification": "...", "confidence": 0.0-1.0}}
@@ -69,6 +61,10 @@ def _rules(text: str) -> tuple[MailClassification, float]:
return MailClassification.OTHER, 0.3
def _has_negation(text: str) -> bool:
return bool(_NEGATIONS.search(text))
def _llm_classify(text: str, cfg: Config) -> tuple[MailClassification, float] | None:
"""Ask the configured LLM. Returns None on any failure (rules take over)."""
from cayceops.llm import get_provider
@@ -100,6 +96,13 @@ def classify(text: str, cfg: Config) -> tuple[MailClassification, float]:
else:
kind, confidence = _rules(text)
# APPROVAL VETO (audit round 2): any negation anywhere in the mail
# downgrades an approval to needs_review. Approval requires a mail with
# zero negations — "cannot currently accept", "never accept",
# "hasn't been approved", "no longer accepting" all land here.
if kind is MailClassification.APPROVAL and _has_negation(text):
return MailClassification.NEEDS_REVIEW, min(confidence, 0.6)
if confidence < cfg.llm_review_threshold:
return MailClassification.NEEDS_REVIEW, confidence
return kind, confidence
+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()
+79
View File
@@ -147,6 +147,85 @@ def _build(cfg: Config, sess) -> str:
return cfg.monitor_output
def _anchor(cfg: Config, sess) -> int:
"""Anchor core (injectable session for tests). Returns 0 ok / 0 skip."""
from cayceops.components.publisher import nostr
from cayceops.config import ConfigError
from cayceops.models import AuditEntry, audit
if not cfg.nostr_private_key or not cfg.nostr_relays:
raise ConfigError(
"anchor requires CAYCE_OPS_NOSTR_PRIVATE_KEY and CAYCE_OPS_NOSTR_RELAYS"
)
if not cfg.deployment_id:
raise ConfigError("anchor requires CAYCE_OPS_DEPLOYMENT_ID")
last = (
sess.query(AuditEntry)
.filter(AuditEntry.entry_hash.isnot(None))
.order_by(AuditEntry.id.desc())
.first()
)
if last is None:
print("nothing to anchor: chain empty")
return 0
today = datetime.now(UTC).date()
last_anchor = (
sess.query(AuditEntry)
.filter(AuditEntry.action == "anchor")
.order_by(AuditEntry.id.desc())
.first()
)
if last_anchor is not None and last_anchor.ts.date() == today:
print("already anchored today")
return 0
secret = nostr.normalize_key(cfg.nostr_private_key)
event = nostr.build_event(
secret,
f"cayce-ops audit anchor deployment={cfg.deployment_id} "
f"last_hash={last.entry_hash} entries={last.id}",
tags=[["t", "cayce-ops"], ["t", "audit-anchor"]],
)
if not nostr.verify_event(event):
raise RuntimeError("anchor event failed self-verification")
results = nostr.publish(cfg.nostr_relays, event)
audit(
sess,
actor="daemon:monitor",
action="anchor",
detail={
"last_hash": last.entry_hash,
"last_entry": last.id,
"event_id": event["id"],
"relays": results,
"deployment": cfg.deployment_id,
},
)
print(f"anchored: {event['id']}")
return 0
def anchor(cfg: Config | None = None) -> int:
"""CLI: anchor once per day (external anchoring, audit round 2 point 4)."""
from cayceops.config import ConfigError, load
from cayceops.db import engine
cfg = cfg or load()
try:
with session_scope() as sess:
try:
return _anchor(cfg, sess)
except ConfigError as exc:
print(f"anchor refused: {exc}")
return 2
except Exception as exc: # noqa: BLE001 — CLI boundary
print(f"anchor failed: {str(exc)[:200]}")
return 1
finally:
engine().dispose()
def build(cfg: Config | None = None) -> int:
"""CLI entry: render the status page inside a DaemonRun bookkeeping wrap."""
from cayceops.config import load
+110 -9
View File
@@ -114,7 +114,33 @@ def _plan(cfg: Config, sess, days: int = 4) -> dict:
# --- 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)
if item is None or item.status != ContentStatus.DRAFT:
audit(
@@ -125,17 +151,58 @@ def _approve(item_id: int, actor: str, sess) -> int:
detail={"reason": f"item {item_id} not in draft state"},
)
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.approved_hash = _content_hash(item.caption) # binding: caption as approved
item.approved_hash = _content_hash(item.caption)
item.operator_signature = signature # FULL signature
audit(
sess,
actor=actor,
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
def cfg_from(sess):
"""Config from env (publisher commands are single-invocation CLIs)."""
from cayceops.config import load
return load()
# --- 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:
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:
# run-time signature gate (audit round 2, 1d): valid operator
# 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(
sess,
actor="daemon:publisher",
action="publish_refused_hash_mismatch",
action="publish_refused",
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
continue
@@ -287,6 +381,13 @@ def publish_due(cfg: Config | None = None) -> int:
run_row = DaemonRun(daemon="publisher")
sess.add(run_row)
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)
run_row.status = "ok"
run_row.detail = stats
@@ -312,11 +413,11 @@ def make_image(item_id: int, cfg: Config | None = None) -> int:
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
try:
with session_scope() as sess:
return _approve(item_id, actor, sess)
return _approve(item_id, actor, sess, signature)
finally:
engine().dispose()
+10 -2
View File
@@ -84,11 +84,15 @@ class Config:
enable_http_actions: bool = field(
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_HTTP_ACTIONS", False)
)
# when set (64-char hex x-only pubkey), human approvals MUST carry a valid
# BIP-340 signature over "cayce-ops:approve:<id>" made by the operator key.
# MANDATORY for approval-gated work: the operator x-only pubkey and the
# deployment identifier are part of every signed approval message.
# Without BOTH, daemons refuse to execute/send/publish — no degraded mode.
operator_pubkey: str = field(
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 ---
imap_host: str = field(
@@ -167,6 +171,10 @@ class Config:
)
class ConfigError(RuntimeError):
"""Raised when mandatory configuration is missing."""
def load() -> Config:
"""Build the process Config snapshot."""
return Config()
+86 -14
View File
@@ -21,9 +21,10 @@ from __future__ import annotations
import enum
import hashlib
import json
import threading
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
@@ -77,6 +78,8 @@ class Proposal(Base):
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_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)
updated_at: Mapped[datetime] = mapped_column(
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)
# 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(
sess,
actor: str,
@@ -113,13 +121,66 @@ def audit(
) -> AuditEntry:
"""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.
Chain: entry_hash = sha256(prev_hash | ts_epoch | actor | action |
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()
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
bind = sess.get_bind()
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()
# 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(
@@ -133,31 +194,40 @@ def audit(
result,
]
)
entry = AuditEntry(
return AuditEntry(
ts=ts,
actor=actor,
action=action,
proposal_id=proposal_id,
detail=detail or {},
result=result,
prev_hash=prev_hash,
prev_hash=prev_hash or "GENESIS",
entry_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
)
sess.add(entry)
return entry
def verify_audit_chain(sess) -> tuple[bool, int | None]:
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None)."""
prev_hash = "GENESIS"
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None).
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():
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)
ts = entry.ts
if ts.tzinfo is None:
ts = ts.replace(tzinfo=UTC)
payload = "|".join(
[
prev_hash,
expected_prev,
str(int(ts.timestamp())),
entry.actor,
entry.action,
@@ -167,7 +237,7 @@ def verify_audit_chain(sess) -> tuple[bool, int | None]:
]
)
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
prev_hash = entry.entry_hash
return True, None
@@ -222,6 +292,7 @@ class MailMessage(Base):
# 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)
operator_signature: Mapped[str | None] = mapped_column(Text, 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)
# binding: hash of the caption the human approved
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)
+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
+178 -20
View File
@@ -1,22 +1,180 @@
-- 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';
-- cayce-ops — least-privilege per-daemon roles (audit round 2, point 3)
--
-- Apply with psql and MANDATORY password variables; the script refuses to run
-- without them (no literal credentials):
--
-- psql -h 127.0.0.1 -U postgres -d cayce_ops -f deploy/roles.sql \
-- -v scanner_password='…' -v executor_password='…' \
-- -v maild_password='…' -v publisher_password='…' \
-- -v monitor_password='…' -v approver_password='…'
--
-- Model:
-- cayce_scanner : INSERT proposals only (+ INSERT audit_log, daemon_runs)
-- cayce_executor : UPDATE proposals WITHOUT approved/signature columns
-- cayce_maild : UPDATE mail_messages WITHOUT approved/signature columns
-- cayce_publisher : INSERT content_items, UPDATE WITHOUT approved/signature
-- cayce_monitor : SELECT everything (+ INSERT audit_log for anchors)
-- cayce_approver : THE ONLY role that can write status='approved',
-- approved_hash, operator_signature, approved_by/at
--
-- A BEFORE trigger rejects status='approved' writes from any role except
-- cayce_approver — column grants alone cannot express that condition.
--
-- NOTE: do NOT apply on production yourself; the operator does.
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;
\set ON_ERROR_STOP on
-- sequences
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor;
\if :{?scanner_password}
\else
\echo 'ERROR: missing variables. Pass -v scanner_password=… executor_password=… maild_password=… publisher_password=… monitor_password=… approver_password=…'
SELECT * FROM _roles_sql_missing_required_password_variables;
\endif
\if :{?executor_password}
\else
\echo 'ERROR: missing executor_password'
SELECT * FROM _roles_sql_missing_required_password_variables;
\endif
\if :{?maild_password}
\else
\echo 'ERROR: missing maild_password'
SELECT * FROM _roles_sql_missing_required_password_variables;
\endif
\if :{?publisher_password}
\else
\echo 'ERROR: missing publisher_password'
SELECT * FROM _roles_sql_missing_required_password_variables;
\endif
\if :{?monitor_password}
\else
\echo 'ERROR: missing monitor_password'
SELECT * FROM _roles_sql_missing_required_password_variables;
\endif
\if :{?approver_password}
\else
\echo 'ERROR: missing approver_password'
SELECT * FROM _roles_sql_missing_required_password_variables;
\endif
-- idempotency: detach old incarnations before dropping
DROP FUNCTION IF EXISTS cayce_ops_approval_gate() CASCADE;
REVOKE ALL ON SCHEMA public FROM
cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM
cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM
cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
DROP ROLE IF EXISTS cayce_scanner;
DROP ROLE IF EXISTS cayce_executor;
DROP ROLE IF EXISTS cayce_maild;
DROP ROLE IF EXISTS cayce_publisher;
DROP ROLE IF EXISTS cayce_monitor;
DROP ROLE IF EXISTS cayce_approver;
CREATE ROLE cayce_scanner LOGIN PASSWORD :'scanner_password';
CREATE ROLE cayce_executor LOGIN PASSWORD :'executor_password';
CREATE ROLE cayce_maild LOGIN PASSWORD :'maild_password';
CREATE ROLE cayce_publisher LOGIN PASSWORD :'publisher_password';
CREATE ROLE cayce_monitor LOGIN PASSWORD :'monitor_password';
CREATE ROLE cayce_approver LOGIN PASSWORD :'approver_password';
GRANT USAGE ON SCHEMA public TO cayce_scanner, cayce_executor, cayce_maild,
cayce_publisher, cayce_monitor, cayce_approver;
-- sequences everybody needs
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
-- common reads
GRANT SELECT ON proposals, audit_log, mail_messages, content_items, daemon_runs
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
-- every daemon appends to audit_log and daemon_runs (never UPDATE/DELETE)
GRANT INSERT ON audit_log
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
GRANT INSERT ON daemon_runs
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor;
-- scanner: propose only
GRANT INSERT ON proposals TO cayce_scanner;
-- executor: transition proposals WITHOUT touching approval columns
GRANT UPDATE (status, attempts, updated_at) ON proposals TO cayce_executor;
-- (there is no error column; adjust if added) — see trigger below for status
-- maild: classify + drafts + send bookkeeping WITHOUT approval columns
GRANT INSERT, UPDATE (classification, confidence, draft_status, draft_reply,
in_reply_to, message_id, from_addr, to_addr, subject, body, processed_at)
ON mail_messages TO cayce_maild;
-- publisher: plan drafts and record publishes WITHOUT approval columns
GRANT INSERT ON content_items TO cayce_publisher;
GRANT UPDATE (status, attempts, error, media_path, scheduled_for, published_at, post_ref)
ON content_items TO cayce_publisher;
-- approver: the ONLY writer of approvals and signatures
GRANT SELECT, UPDATE ON proposals TO cayce_approver;
GRANT SELECT, UPDATE ON mail_messages TO cayce_approver;
GRANT SELECT, UPDATE ON content_items TO cayce_approver;
GRANT INSERT ON audit_log TO cayce_approver;
-- ---------------------------------------------------------------------------
-- approval gate: status/draft_status='approved' and signature columns may
-- only be written by cayce_approver (one trigger per table — column sets differ)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION cayce_ops_gate_proposals() RETURNS trigger AS $$
BEGIN
IF current_user IN ('cayce_approver', 'postgres') THEN
RETURN NEW;
END IF;
IF NEW.status::text = 'APPROVED' THEN
RAISE EXCEPTION 'cayce-ops: only cayce_approver may set proposals.status=approved (user=%)', current_user;
END IF;
IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN
RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION cayce_ops_gate_mail() RETURNS trigger AS $$
BEGIN
IF current_user IN ('cayce_approver', 'postgres') THEN
RETURN NEW;
END IF;
IF NEW.draft_status::text = 'APPROVED' THEN
RAISE EXCEPTION 'cayce-ops: only cayce_approver may set mail draft_status=approved (user=%)', current_user;
END IF;
IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN
RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION cayce_ops_gate_content() RETURNS trigger AS $$
BEGIN
IF current_user IN ('cayce_approver', 'postgres') THEN
RETURN NEW;
END IF;
IF NEW.status::text = 'APPROVED' THEN
RAISE EXCEPTION 'cayce-ops: only cayce_approver may set content status=approved (user=%)', current_user;
END IF;
IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN
RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS proposals_approval_gate ON proposals;
CREATE TRIGGER proposals_approval_gate BEFORE INSERT OR UPDATE ON proposals
FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_proposals();
DROP TRIGGER IF EXISTS mail_approval_gate ON mail_messages;
CREATE TRIGGER mail_approval_gate BEFORE INSERT OR UPDATE ON mail_messages
FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_mail();
DROP TRIGGER IF EXISTS content_approval_gate ON content_items;
CREATE TRIGGER content_approval_gate BEFORE INSERT OR UPDATE ON content_items
FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_content();
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=cayce-ops publisher plan (fills the content calendar, daily)
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=oneshot
User=cayce
WorkingDirectory=/srv/cayce-ops
EnvironmentFile=/srv/cayce-ops/.env
ExecStart=/srv/cayce-ops/.venv/bin/cayceops publisher plan
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/srv/cayce-ops
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Run cayce-ops publisher plan daily
[Timer]
OnCalendar=*-*-* 06:00:00
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target
+22
View File
@@ -95,3 +95,25 @@ 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.
## D13 — No non-human approval path (round 2, supersedes D5)
Operator signatures are mandatory for approvals AND re-verified at
execution/send/publish time against current content + deployment id. With
that gate, the AUTONOMOUS_MODE auto-approval path cannot exist: it would
produce unsigned approvals the gate refuses. `_auto_approve` is inert and
audits `autonomy_disabled` when invoked. *Cost:* AUTONOMOUS_MODE is now
inert by design.
## D14 — Signatures bind content + deployment (round 2)
message = sha256("cayce-ops:<type>:<deployment_id>:<id>:<content_hash>").
content_hash is canonical-json of the fields that matter per type. Full
signatures live on the row (operator_signature) and in the audit detail.
Verification happens again at run time: unsigned rows (raw SQL approval)
revert to their pre-approval state with an error audit. *Cost:* a deployment
id env var is mandatory; signatures must be recomputed if content changes.
## D15 — Approval classifier veto (round 2)
Any negation token anywhere in a mail vetoes an `approval` classification →
needs_review. Simpler and strictly conservative vs. per-pattern negation
lists. *Cost:* genuinely-positive approvals containing stray negations
(e.g. "we have no reason not to welcome you") need human triage.
+65
View File
@@ -10,8 +10,14 @@ from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
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.
os.environ.setdefault("CAYCE_OPS_DATABASE_URL", "sqlite:///:memory:")
@@ -54,6 +60,61 @@ def sess(db_engine):
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()
def cfg(monkeypatch):
"""A Config snapshot with test defaults (no network, no autonomy)."""
@@ -66,6 +127,10 @@ def cfg(monkeypatch):
"CAYCE_OPS_ENABLE_INSTAGRAM": "false",
"CAYCE_OPS_NOSTR_RELAYS": "",
"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):
View File
+289
View File
@@ -0,0 +1,289 @@
"""Integration tests against Postgres 16: roles.sql + one full daemon cycle
per role (audit round 2, point 3).
Skipped unless a Postgres is reachable at TEST_PG_DSN (default: the
docker-compose service). Roles are applied by piping deploy/roles.sql
through psql WITH the mandatory -v password variables — exercising the same
path an operator uses; the test skips if the psql client is not installed.
Run: uv run pytest -m integration -v
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
pytestmark = pytest.mark.integration
PG_DSN = os.environ.get(
"TEST_PG_DSN",
"postgresql://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops",
)
ROLES_SQL = Path(__file__).resolve().parents[2] / "deploy" / "roles.sql"
ROLE_PASSWORDS = {
"scanner_password": "itest-scanner-pw",
"executor_password": "itest-executor-pw",
"maild_password": "itest-maild-pw",
"publisher_password": "itest-publisher-pw",
"monitor_password": "itest-monitor-pw",
"approver_password": "itest-approver-pw",
}
def _pg_available() -> bool:
try:
import psycopg
with psycopg.connect(PG_DSN, connect_timeout=3):
return True
except Exception: # noqa: BLE001
return False
def _psql_available() -> bool:
try:
return (
subprocess.run(
["psql", "--version"], capture_output=True, timeout=10
).returncode
== 0
)
except (OSError, subprocess.SubprocessError):
return False
@pytest.fixture(scope="module")
def roles_applied():
if not _pg_available():
pytest.skip("Postgres not reachable (set TEST_PG_DSN)")
if not _psql_available():
pytest.skip("psql client not installed")
# fresh slate on an admin connection
import psycopg
admin = psycopg.connect(PG_DSN, autocommit=True)
with admin.cursor() as cur:
cur.execute(
"""
DROP TABLE IF EXISTS audit_log, mail_messages, content_items,
daemon_runs, proposals, alembic_version CASCADE;
DROP TYPE IF EXISTS proposalsource, proposalstatus, maildirection,
mailclassification, draftstatus, contentstatus CASCADE;
DROP FUNCTION IF EXISTS cayce_ops_approval_gate() CASCADE;
"""
)
admin.close()
# apply alembic chain (imports app; config must point at PG first)
os.environ["CAYCE_OPS_DATABASE_URL"] = PG_DSN.replace(
"postgresql://", "postgresql+psycopg://"
)
import subprocess as sp
env = {**os.environ, "CAYCE_OPS_DATABASE_URL": os.environ["CAYCE_OPS_DATABASE_URL"]}
# upgrade -> downgrade -> upgrade round-trip on Postgres (audit round 2,
# point 6): exercises 0002.downgrade and 0003.downgrade for real
sp.run(["uv", "run", "alembic", "upgrade", "head"], check=True, env=env)
sp.run(["uv", "run", "alembic", "downgrade", "0001"], check=True, env=env)
sp.run(["uv", "run", "alembic", "upgrade", "head"], check=True, env=env)
# apply roles.sql the operator way: psql -v <password vars>
cmd = ["psql", PG_DSN, "-v", "ON_ERROR_STOP=1", "-f", str(ROLES_SQL)]
for var, value in ROLE_PASSWORDS.items():
cmd += ["-v", f"{var}={value}"]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
assert result.returncode == 0, f"roles.sql failed:\n{result.stderr}"
# re-apply WITHOUT variables must fail (audit requirement: no silent run)
bad = subprocess.run(
["psql", PG_DSN, "-v", "ON_ERROR_STOP=1", "-f", str(ROLES_SQL)],
capture_output=True,
text=True,
timeout=60,
)
assert bad.returncode != 0, "roles.sql must refuse to run without vars"
yield
def _connect_as(role: str):
"""Connect with the role's credentials on the same DB as PG_DSN."""
import psycopg
key = f"{role.split('_', 1)[1]}_password"
after_scheme = PG_DSN.split("://", 1)[1]
userinfo, hostport_db = after_scheme.split("@", 1)
hostport, _db = hostport_db.split("/", 1)
dsn = f"postgresql://{role}:{ROLE_PASSWORDS[key]}@{hostport}/{_db}"
return psycopg.connect(dsn, autocommit=True)
def _must_fail(conn, sql: str, because: str):
import psycopg
try:
with conn.cursor() as cur:
cur.execute(sql)
except psycopg.Error as exc:
assert because.lower() in str(exc).lower(), f"{because} — got: {exc}"
conn.rollback()
return
pytest.fail(f"expected failure: {because}")
def test_roles_full_daemon_cycle(roles_applied):
# --- scanner role: proposes (INSERT proposals) ---
with _connect_as("cayce_scanner") as conn, conn.cursor() as cur:
cur.execute(
"""INSERT INTO proposals (source, kind, title, url, detail,
match_score, status, attempts, created_at, updated_at)
VALUES ('SCANNER', 'partnership', 'Acme partner', NULL, '{}',
0.9, 'PROPOSED', 0, now(), now()) RETURNING id"""
)
proposal_id = cur.fetchone()[0]
cur.execute(
"INSERT INTO audit_log (ts, actor, action, detail, result) "
"VALUES (now(), 'daemon:scanner', 'propose', '{}', 'ok')"
)
cur.execute(
"INSERT INTO daemon_runs (daemon, started_at, status, detail) "
"VALUES ('scanner', now(), 'ok', '{}')"
)
# scanner cannot approve: no UPDATE grant at all, and the trigger would
# still block status=APPROVED for non-approver roles
scanner = _connect_as("cayce_scanner")
_must_fail(
scanner,
f"UPDATE proposals SET status='APPROVED' WHERE id={proposal_id}",
because="permission denied",
)
scanner.close()
# executor cannot approve either (trigger), and cannot write signatures
executor = _connect_as("cayce_executor")
# the signature column is outside the executor's column grant: the whole
# UPDATE is denied before the trigger even runs
fake_sig = "ab" * 64 # invented 128-hex signature
_must_fail(
executor,
f"UPDATE proposals SET status='APPROVED', operator_signature='{fake_sig}' "
f"WHERE id={proposal_id}",
because="permission denied",
)
# executor CAN run its cycle once the approver approved
with executor.cursor() as cur:
cur.execute(
f"UPDATE proposals SET status='RUNNING', attempts=attempts+1, "
f"updated_at=now() WHERE id={proposal_id}"
)
cur.execute(
f"UPDATE proposals SET status='DONE', updated_at=now() WHERE id={proposal_id}"
)
cur.execute(
"INSERT INTO daemon_runs (daemon, started_at, status, detail) "
"VALUES ('executor', now(), 'ok', '{}')"
)
executor.close()
# --- approver: the only role that can approve + sign ---
sig = "c9" * 64 # invented 128-hex signature for the permission test
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
cur.execute(
f"UPDATE proposals SET status='APPROVED', approved_by='gustavo', "
f"approved_at=now(), operator_signature='{sig}' WHERE id={proposal_id}"
)
# --- maild role: drafts + sends, cannot approve ---
with _connect_as("cayce_maild") as conn, conn.cursor() as cur:
cur.execute(
"""INSERT INTO mail_messages (direction, message_id, from_addr,
subject, body, draft_reply, draft_status, confidence)
VALUES ('OUTBOUND', '<itest@x>', 'desk@acme.example.org', 'Hello',
'Body', 'Body', 'DRAFTED', 0) RETURNING id"""
)
mail_id = cur.fetchone()[0]
maild = _connect_as("cayce_maild")
_must_fail(
maild,
f"UPDATE mail_messages SET draft_status='APPROVED' WHERE id={mail_id}",
because="only cayce_approver may set mail draft_status=approved",
)
_must_fail(
maild,
f"UPDATE mail_messages SET operator_signature='{sig}' WHERE id={mail_id}",
because="permission denied", # signature column outside maild's grant
)
# the approver approves; then maild can mark it sent
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
cur.execute(
"UPDATE mail_messages SET draft_status='APPROVED', "
"approved_by='gustavo', approved_hash='"
+ "a" * 64
+ f"', operator_signature='{sig}' WHERE id={mail_id}"
)
with _connect_as("cayce_maild") as conn, conn.cursor() as cur:
cur.execute(
f"UPDATE mail_messages SET draft_status='SENT' WHERE id={mail_id}"
)
maild.close()
# --- publisher role: plans content, cannot approve ---
with _connect_as("cayce_publisher") as conn, conn.cursor() as cur:
cur.execute(
"""INSERT INTO content_items (theme, channel, caption, status,
attempts, created_at)
VALUES ('atm-fees', 'nostr', 'caption', 'DRAFT', 0, now())
RETURNING id"""
)
item_id = cur.fetchone()[0]
publisher = _connect_as("cayce_publisher")
_must_fail(
publisher,
f"UPDATE content_items SET status='APPROVED' WHERE id={item_id}",
because="only cayce_approver may set content status=approved",
)
# approver approves; publisher publishes
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
cur.execute(
"UPDATE content_items SET status='APPROVED', approved_hash='"
+ "b" * 64
+ f"', operator_signature='{sig}' WHERE id={item_id}"
)
with _connect_as("cayce_publisher") as conn, conn.cursor() as cur:
cur.execute(
f"UPDATE content_items SET status='PUBLISHED', published_at=now() "
f"WHERE id={item_id}"
)
publisher.close()
# --- monitor role: read-only + audit anchors ---
monitor = _connect_as("cayce_monitor")
with monitor.cursor() as cur:
cur.execute("SELECT count(*) FROM proposals")
cur.fetchone()
cur.execute(
"INSERT INTO audit_log (ts, actor, action, detail, result) "
"VALUES (now(), 'daemon:monitor', 'anchor', '{}', 'ok')"
)
# monitor CAN book its runs, but cannot touch proposals
with monitor.cursor() as cur:
cur.execute(
"INSERT INTO daemon_runs (daemon, started_at, status, detail) "
"VALUES ('monitor', now(), 'ok', '{}')"
)
_must_fail(
monitor,
"INSERT INTO proposals (source, kind, title, detail, match_score, status, "
"attempts, created_at, updated_at) "
"VALUES ('SCANNER', 'other', 'x', '{}', 0, 'PROPOSED', 0, now(), now())",
because="permission denied",
)
monitor.close()
+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
@@ -12,16 +17,22 @@ def _proposal(kind="content_gap", title="Test item", **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()
sess.add(p)
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)
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.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):
@@ -29,75 +40,26 @@ def test_reject_records_actor(cfg, sess):
sess.add(p)
sess.flush()
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):
"""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, signer):
approved = _proposal(title="Approved item")
proposed = _proposal(title="Proposed item")
sess.add_all([approved, proposed])
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
assert executor.run(config, sess) == 0
assert approved.status == ProposalStatus.DONE
assert proposed.status == ProposalStatus.PROPOSED # untouched
assert approved.status is ProposalStatus.DONE
assert proposed.status is ProposalStatus.PROPOSED # untouched
acts = [a.action for a in sess.query(AuditEntry).order_by(AuditEntry.id).all()]
assert "execute_start" in acts and "execute_done" in acts
@@ -110,36 +72,127 @@ def test_autonomy_off_by_default(cfg, sess):
sess.flush()
config = cfg() # autonomous_mode False even with kinds configured
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):
p1 = _proposal(kind="content_gap", title="auto ok")
p2 = _proposal(kind="content_gap", title="auto over cap")
p3 = _proposal(kind="affiliate", title="kind not allowed")
sess.add_all([p1, p2, p3])
def test_autonomy_cannot_approve_anymore(cfg, sess):
"""Round-2 1a: with mandatory signatures there is NO non-human approval
path. AUTONOMOUS_MODE no longer approves anything (D13)."""
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",
)
approved = executor._auto_approve(config, sess)
assert approved == 1
assert p1.status == ProposalStatus.APPROVED
assert p2.status == ProposalStatus.PROPOSED # cap reached
assert p3.status == ProposalStatus.PROPOSED # kind not in policy
actor = sess.query(AuditEntry).filter_by(action="approve").one().actor
assert actor == "auto:policy"
assert executor._auto_approve(config, sess) == 0
assert low.status is ProposalStatus.PROPOSED
assert high.status is ProposalStatus.PROPOSED
entry = sess.query(AuditEntry).filter_by(action="autonomy_disabled").one()
assert entry.result == "error"
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")
sess.add(p)
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}
@@ -151,11 +204,11 @@ def test_failing_plugin_retries_then_fails(cfg, sess, monkeypatch):
config = cfg(CAYCE_OPS_MAX_ATTEMPTS="2")
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
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 calls["n"] == 2
@@ -174,14 +227,16 @@ def test_http_post_blocked_when_disabled(cfg, 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")
sess.add(p)
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 p.status == ProposalStatus.DONE
assert p.status is ProposalStatus.DONE
done = sess.query(AuditEntry).filter_by(action="execute_done").one()
assert done.detail["outcome"]["plugin_fallback"] is True
+195 -16
View File
@@ -2,7 +2,10 @@
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 maild
@@ -16,9 +19,20 @@ from cayceops.models import (
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>"
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:
@@ -34,9 +48,24 @@ def _eml(subject, body, in_reply_to=None, msgid=None) -> bytes:
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:
"""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(
kind="partnership",
title="Acme hotel affiliate program",
@@ -47,7 +76,10 @@ def _proposal_with_sent_draft(sess) -> Proposal:
mail_id = maild._compose(
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"
out = (
sess.query(MailMessage)
@@ -72,8 +104,15 @@ def test_rules_classification(cfg):
clf.classify("Your application was approved, welcome to the program", config)[0]
is MailClassification.APPROVAL
)
# negation present -> approval is vetoed to needs_review (round-2 rule;
# the human decides between rejection and other)
assert (
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
)
assert (
@@ -84,10 +123,16 @@ def test_rules_classification(cfg):
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."""
def test_negated_approvals_never_classify_approval(cfg):
"""Round-2 point 2: any negation vetoes `approval` -> needs_review."""
config = cfg()
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 are not accepting new partners at this time",
"Your application was not approved",
@@ -95,9 +140,8 @@ def test_negated_approvals_are_rejections(cfg):
"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
kind, _ = clf.classify(text, config)
assert kind is not MailClassification.APPROVAL, f"{text!r} -> {kind}"
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()
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 = []
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]))
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
@@ -256,7 +310,10 @@ def test_send_failure_does_not_resurrect_sent_mails(cfg, sess):
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._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"
sess.flush()
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>")
.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
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)
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
assert (
maild._approve_draft(
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
)
== 0
)
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 sent == []
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"
@@ -334,7 +401,10 @@ def test_compose_creates_first_contact_draft(cfg, sess):
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
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)
assert stats["sent"] == 1
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]))
assert stats["processed"] == 1
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"
ok, broken = verify_audit_chain(sess)
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
+39
View File
@@ -67,3 +67,42 @@ def test_build_escapes_injected_content(cfg, sess, tmp_path):
page = out.read_text()
assert "<script>x</script>" not in page
assert "&lt;script&gt;x&lt;/script&gt;" in page
def test_anchor_signs_and_logs_once_per_day(cfg, sess, monkeypatch):
from cayceops.components.monitor import monitor
from cayceops.models import AuditEntry
# seed the chain
from cayceops.models import audit as audit_fn
audit_fn(sess, actor="daemon:executor", action="execute_done", detail={})
sess.flush()
published = []
def fake_publish(relays, event, timeout=10.0):
published.append(event)
return [{"relay": r, "ok": "true"} for r in relays]
import cayceops.components.publisher.nostr as nostr_mod
monkeypatch.setattr(nostr_mod, "publish", fake_publish)
config = cfg(
CAYCE_OPS_NOSTR_PRIVATE_KEY="11" * 32,
CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org",
CAYCE_OPS_DEPLOYMENT_ID="itest",
)
code = monitor._anchor(config, sess)
assert code == 0
assert len(published) == 1
content = published[0]["content"]
assert "audit anchor" in content and "itest" in content
entry = sess.query(AuditEntry).filter_by(action="anchor").one()
assert entry.detail["last_hash"]
# second call same day -> skipped, no new nostr event
code2 = monitor._anchor(config, sess)
assert code2 == 0
assert len(published) == 1
+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
import os
from datetime import UTC, datetime, timedelta
import pytest
from cayceops.components.publisher import adapters, nostr, publisher
from cayceops.models import AuditEntry, ContentItem, ContentStatus
# invented test key (never a real nostr key — allowlisted in .gitleaks.toml)
TEST_KEY = "11" * 32
# invented test keys (never real — allowlisted in .gitleaks.toml)
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):
@@ -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 -------------------------------------------------------------
@@ -65,10 +89,16 @@ def test_approve_gate(cfg, sess):
item = _item(status=ContentStatus.DRAFT)
sess.add(item)
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 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
assert actor == "gustavo"
@@ -78,9 +108,7 @@ def test_approve_gate(cfg, sess):
def test_publish_due_nostr_happy_path(cfg, sess):
item = _item() # approved, scheduled None -> due now
sess.add(item)
sess.flush()
item = _approved_item(cfg, sess)
relays_seen = []
@@ -99,25 +127,69 @@ def test_publish_due_nostr_happy_path(cfg, sess):
assert item.status is ContentStatus.PUBLISHED
assert item.post_ref and len(item.post_ref) == 64
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):
from datetime import UTC, datetime, timedelta
item = _item(scheduled=datetime.now(UTC) + timedelta(hours=2))
sess.add(item)
sess.flush()
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: [])
item = _approved_item(
cfg, sess, scheduled=datetime.now(UTC) + timedelta(hours=2)
)
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
assert item.status is ContentStatus.APPROVED
def test_nostr_without_config_fails_clean(cfg, sess):
item = _item()
def test_unsigned_sql_approved_item_not_published(cfg, sess):
"""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.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
stats = publisher._publish_due(config, sess)
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):
item = _item(channel="facebook", theme="lightning-abroad")
sess.add(item)
sess.flush()
item = _approved_item(cfg, sess, channel="facebook", theme="lightning-abroad")
stats = publisher._publish_due(cfg(), sess)
assert stats["skipped"] == 1
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):
@@ -164,61 +237,27 @@ def test_unknown_brand_rejected():
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
# --- round-2: attempts budget --------------------------------------------------
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",
)
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)
assert item.attempts == 1
assert item.status is ContentStatus.SKIPPED # budget exhausted -> terminal
import os # noqa: E402 (used by image test)