audit-1 (critical 1): mandatory operator signatures bound to content+deployment
- signing module: canonical content hashes + approval_message sha256(cayce-ops:<type>:<deployment_id>:<id>:<content_hash>) - approve/approve-draft/publisher-approve REQUIRE signature; without CAYCE_OPS_OPERATOR_PUBKEY+DEPLOYMENT_ID the daemons refuse to execute/send/publish (no degraded mode) - run-time re-verification at execute/send/publish: unsigned rows (raw SQL approval) or edited content revert to pre-approval state, audited as error - FULL signatures stored on rows (operator_signature, migration 0003) and in audit detail — never truncated - signature failures audited from CLI wrappers too (1f) - AUTONOMOUS_MODE approval path removed (D13): no non-human approvals tests: SQL-approved row refused at execute/send/publish; to_addr change blocks send; foreign deployment/content signatures refused; full-signature persistence. 65 tests green.
This commit is contained in:
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user