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.
This commit is contained in:
@@ -29,12 +29,59 @@ from cayceops.models import AuditEntry, DaemonRun, Proposal, ProposalStatus, aud
|
||||
Actor = str
|
||||
|
||||
|
||||
def _load_cfg():
|
||||
from cayceops.config import load
|
||||
|
||||
return load()
|
||||
|
||||
|
||||
# --- human transitions -------------------------------------------------------
|
||||
|
||||
# message signed by the operator key when CAYCE_OPS_OPERATOR_PUBKEY is set
|
||||
def approval_message(proposal_id: int) -> bytes:
|
||||
import hashlib
|
||||
|
||||
def approve(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
|
||||
"""Human approves a proposal. Audited with the operator's identity."""
|
||||
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess)
|
||||
return hashlib.sha256(f"cayce-ops:approve:{proposal_id}".encode()).digest()
|
||||
|
||||
|
||||
def approve(
|
||||
proposal_id: int,
|
||||
actor: Actor,
|
||||
cfg: Config | None = None,
|
||||
sess=None,
|
||||
signature: str | None = None,
|
||||
) -> int:
|
||||
"""Human approves a proposal. Audited with the operator's identity.
|
||||
|
||||
When CAYCE_OPS_OPERATOR_PUBKEY is configured, `signature` (hex, BIP-340
|
||||
over approval_message(id)) is REQUIRED and verified against that key —
|
||||
'actor' then is a label, the key is the proof (audit finding 1).
|
||||
"""
|
||||
from cayceops.components.publisher.nostr import verify_schnorr
|
||||
|
||||
cfg = cfg or _load_cfg()
|
||||
if cfg.operator_pubkey:
|
||||
ok = bool(signature) and verify_schnorr(
|
||||
signature, approval_message(proposal_id), cfg.operator_pubkey
|
||||
)
|
||||
if not ok and sess is not None:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve",
|
||||
proposal_id=proposal_id,
|
||||
result="error",
|
||||
detail={
|
||||
"reason": (
|
||||
"operator pubkey configured: signature required"
|
||||
if not signature
|
||||
else "invalid approval signature"
|
||||
)
|
||||
},
|
||||
)
|
||||
if not ok:
|
||||
return 2
|
||||
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess, signature)
|
||||
|
||||
|
||||
def reject(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
|
||||
@@ -48,6 +95,7 @@ def _decide(
|
||||
decision: ProposalStatus,
|
||||
cfg: Config | None,
|
||||
sess,
|
||||
signature: str | None = None,
|
||||
) -> int:
|
||||
owned = False
|
||||
if sess is None:
|
||||
@@ -70,11 +118,14 @@ def _decide(
|
||||
)
|
||||
return 1
|
||||
proposal.status = decision
|
||||
detail: dict = {}
|
||||
if decision == ProposalStatus.APPROVED:
|
||||
proposal.approved_by = actor
|
||||
proposal.approved_at = datetime.now(UTC)
|
||||
if signature:
|
||||
detail["signature"] = signature[:32] + "..."
|
||||
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
|
||||
audit(sess, actor=actor, action=verb, proposal_id=proposal_id)
|
||||
audit(sess, actor=actor, action=verb, proposal_id=proposal_id, detail=detail)
|
||||
return 0
|
||||
finally:
|
||||
if owned:
|
||||
@@ -102,13 +153,15 @@ def _auto_approve(cfg: Config, sess) -> int:
|
||||
approved = 0
|
||||
if budget == 0:
|
||||
return 0
|
||||
# most relevant first (match_score = keyword relevance, NOT risk — the
|
||||
# audit's naming finding; ascending order approved noise first)
|
||||
candidates = (
|
||||
sess.query(Proposal)
|
||||
.filter(
|
||||
Proposal.status == ProposalStatus.PROPOSED,
|
||||
Proposal.kind.in_(cfg.autonomous_kinds),
|
||||
)
|
||||
.order_by(Proposal.risk_score.asc(), Proposal.id.asc())
|
||||
.order_by(Proposal.match_score.desc(), Proposal.id.asc())
|
||||
.limit(budget)
|
||||
.all()
|
||||
)
|
||||
@@ -124,7 +177,7 @@ def _auto_approve(cfg: Config, sess) -> int:
|
||||
detail={
|
||||
"reason": "autonomous_mode policy",
|
||||
"kind": proposal.kind,
|
||||
"risk_score": proposal.risk_score,
|
||||
"match_score": proposal.match_score,
|
||||
"daily_cap": cfg.autonomous_daily_cap,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user