Files
cayce-ops/tests/test_executor.py
T
cayce 90e6cfcc19 fix: address external audit — criticals 1-5 + mediums
Critical 1 (approval forgery): audit_log is now hash-chained
(prev_hash/entry_hash, verify_audit_chain); executor approve REQUIRES a
BIP-340 signature over sha256(cayce-ops:approve:<id>) when
CAYCE_OPS_OPERATOR_PUBKEY is set; deploy/roles.sql ships least-privilege
per-daemon DB roles.

Critical 2 (threading dead): sends create real OUTBOUND rows (own
Message-ID, In-Reply-To -> inbound); inbound message_id never overwritten;
maild compose adds the first-contact path; _match_proposal falls back to
inbound chains.

Critical 3 (duplicate sends): per-message transactions in send path —
SMTP failure on message N no longer resurrects 1..N-1; failures audited
(send_failed) and the row stays queued.

Critical 4 (classifier): negated approvals (cannot accept / not approved /
not accepting) classify REJECTION at 0.95 before the bare-keyword rule;
LLM prompt braces escaped (.format no longer KeyError into silent fallback).

Critical 5 (systemd): every daemon has a run subcommand
(maild run = poll+send, publisher run = publish-due, monitor run = build);
deploy README documents the separate daily publisher plan timer.

Mediums: approved_hash binds drafts/captions to the approved text (edited
text refuses to send/publish until re-approved); risk_score renamed
match_score (migration 0002) and auto-approve orders HIGHEST relevance
first; compose binds postgres to 127.0.0.1; IMAP marks \\Seen only after
commit (fetch returns (num, raw), mark post-commit); disabled channels are
terminal (ContentStatus.SKIPPED) and publish failures burn a bounded
attempt budget before abandonment; make hooks installs the pre-push gate.

tests: 12 new — 56 total, all green; ruff 0 findings; gitleaks clean.
2026-10-19 23:06:29 +00:00

188 lines
6.2 KiB
Python

"""executor tests — human gate, autonomy bounds, retries, audit chain."""
from __future__ import annotations
import pytest
from cayceops.components.executor import executor, plugins
from cayceops.models import AuditEntry, Proposal, ProposalStatus
def _proposal(kind="content_gap", title="Test item", **kw):
return Proposal(kind=kind, title=title, **kw)
def test_approve_requires_proposed_state(cfg, sess):
p = _proposal()
sess.add(p)
sess.flush()
assert executor.approve(p.id, "gustavo", cfg(), sess) == 0
# second approve must fail (state is now approved)
assert executor.approve(p.id, "gustavo", cfg(), sess) == 1
assert p.approved_by == "gustavo"
assert p.status == ProposalStatus.APPROVED
def test_reject_records_actor(cfg, sess):
p = _proposal()
sess.add(p)
sess.flush()
assert executor.reject(p.id, "gustavo", cfg(), sess) == 0
assert p.status == ProposalStatus.REJECTED
def test_signed_approval_required_when_operator_key_configured(cfg, sess):
"""Critical finding 1: --actor was free text; now a key can be required."""
from coincurve import PrivateKey, PublicKey
from cayceops.components.executor import executor
secret = bytes.fromhex("22" * 32)
pubkey = PublicKey.from_secret(secret).format().hex()[2:]
p = _proposal()
sess.add(p)
sess.flush()
config = cfg(CAYCE_OPS_OPERATOR_PUBKEY=pubkey)
# no signature -> refused
assert executor.approve(p.id, "gustavo", config, sess, signature=None) == 2
# wrong key -> refused
bad = (
PrivateKey(bytes.fromhex("33" * 32))
.sign_schnorr(executor.approval_message(p.id), aux_randomness=b"\x00" * 32)
.hex()
)
assert executor.approve(p.id, "gustavo", config, sess, signature=bad) == 2
# correct key -> approved
good = (
PrivateKey(secret)
.sign_schnorr(executor.approval_message(p.id), aux_randomness=b"\x00" * 32)
.hex()
)
assert executor.approve(p.id, "gustavo", config, sess, signature=good) == 0
assert p.status is ProposalStatus.APPROVED
entry = sess.query(AuditEntry).filter_by(action="approve", result="ok").one()
assert "signature" in entry.detail
def test_auto_approve_orders_by_relevance_desc(cfg, sess):
"""Medium finding: ascending 'risk_score' approved noise first."""
low = _proposal(kind="content_gap", title="barely relevant")
low.match_score = 0.1
high = _proposal(kind="content_gap", title="highly relevant")
high.match_score = 0.9
sess.add_all([low, high])
sess.flush()
config = cfg(
CAYCE_OPS_AUTONOMOUS_MODE="true",
CAYCE_OPS_AUTONOMOUS_KINDS="content_gap",
CAYCE_OPS_AUTONOMOUS_DAILY_CAP="1",
)
executor._auto_approve(config, sess)
assert high.status is ProposalStatus.APPROVED
assert low.status is ProposalStatus.PROPOSED
def test_run_executes_only_approved(cfg, sess):
approved = _proposal(title="Approved item")
proposed = _proposal(title="Proposed item")
sess.add_all([approved, proposed])
sess.flush()
executor.approve(approved.id, "gustavo", cfg(), sess)
config = cfg() # AUTONOMOUS_MODE=false
assert executor.run(config, sess) == 0
assert approved.status == ProposalStatus.DONE
assert proposed.status == 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
assert acts.count("approve") == 1
def test_autonomy_off_by_default(cfg, sess):
p = _proposal(kind="content_gap", title="Never auto-runs")
sess.add(p)
sess.flush()
config = cfg() # autonomous_mode False even with kinds configured
executor._auto_approve(config, sess)
assert p.status == 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])
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"
def test_failing_plugin_retries_then_fails(cfg, sess, monkeypatch):
p = _proposal(kind="affiliate", title="Will fail")
sess.add(p)
sess.flush()
executor.approve(p.id, "gustavo", cfg(), sess)
calls = {"n": 0}
def boom(proposal, cfg, sess):
calls["n"] += 1
raise RuntimeError("upstream exploded")
monkeypatch.setitem(plugins.REGISTRY, "affiliate", boom)
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.attempts == 1
executor.run(config, sess) # attempt 2 -> failed for good
assert p.status == ProposalStatus.FAILED
assert p.attempts == 2
assert calls["n"] == 2
errors = sess.query(AuditEntry).filter_by(result="error").all()
assert any(a.action == "fail" for a in errors)
def test_http_post_blocked_when_disabled(cfg, sess):
p = _proposal(kind="affiliate", title=" webhook target")
p.detail = {"webhook_url": "https://hooks.example.org/ingest"}
sess.add(p)
sess.flush()
config = cfg() # CAYCE_OPS_ENABLE_HTTP_ACTIONS=false
with pytest.raises(PermissionError):
plugins.http_post(p, config, sess)
def test_unknown_kind_falls_back_to_log_action(cfg, sess):
p = _proposal(kind="brand_new_kind", title="No plugin yet")
sess.add(p)
sess.flush()
executor.approve(p.id, "gustavo", cfg(), sess)
assert executor.run(cfg(), sess) == 0
assert p.status == ProposalStatus.DONE
done = sess.query(AuditEntry).filter_by(action="execute_done").one()
assert done.detail["outcome"]["plugin_fallback"] is True