ci / lint-test-leaks (pull_request) Canceled after 0s
Background: CAYCE_OPS_OPERATOR_PUBKEY was set to Cayce nostr pubkey — Cayce was approving itself. Signature was technically valid but semantically null: human approval signed by the agent is not human approval. - signing.py: validate_no_self_approval() rejects the operator pubkey when it matches (a) the pubkey derived from any private key in the config, or (b) any pubkey in CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS deny-list. - executor/maild/publisher _signing() now calls the validation, so every daemon and every CLI approve-* path refuses to operate (D16). - tests: 6 new (agent-key refusal, human key passes, empty passes, explicit denylist, end-to-end daemon refusal, derivation roundtrip). - docs/DECISIONS.md: D16. The operator signature is now produced OUTSIDE the VPS. CAYCE_OPS_OPERATOR_PUBKEY must stay empty until the external signer exists — daemons refuse, which is correct. The Cayce nsec itself is NOT touched (still the agent identity for publishing and anchoring).
409 lines
13 KiB
Python
409 lines
13 KiB
Python
"""executor — executes APPROVED proposals through registered action plugins.
|
|
|
|
Guarantees
|
|
----------
|
|
- Only ``approved`` proposals execute (``AUTONOMOUS_MODE=false`` default).
|
|
- With ``AUTONOMOUS_MODE=true`` the executor gains exactly one capability:
|
|
bounded auto-approval of ``proposed`` items whose ``kind`` is in
|
|
``CAYCE_OPS_AUTONOMOUS_KINDS``, capped by ``CAYCE_OPS_AUTONOMOUS_DAILY_CAP``,
|
|
recorded with actor ``auto:policy`` (DECISIONS D5).
|
|
- Every transition writes an ``audit_log`` row: who approved, what ran,
|
|
plugin output, success/failure.
|
|
- Unknown proposal kinds fall back to the ``log_action`` plugin (never a
|
|
network side effect). See DECISIONS D9.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
from cayceops.components.executor import plugins as plugin_registry
|
|
from cayceops.config import Config
|
|
from cayceops.db import session_scope
|
|
from cayceops.models import AuditEntry, DaemonRun, Proposal, ProposalStatus, audit
|
|
|
|
Actor = str
|
|
|
|
|
|
def _load_cfg():
|
|
from cayceops.config import load
|
|
|
|
return load()
|
|
|
|
|
|
# --- human transitions -------------------------------------------------------
|
|
|
|
GATE_ERROR = 2 # missing/invalid configuration or signature
|
|
|
|
|
|
def _signing(cfg: Config) -> tuple[str, str]:
|
|
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode.
|
|
|
|
Also rejects the agent's own identity as operator (D16): the operator
|
|
key must belong to a human and must never reside on this server.
|
|
"""
|
|
from cayceops.config import ConfigError
|
|
from cayceops.signing import validate_no_self_approval
|
|
|
|
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"
|
|
)
|
|
validate_no_self_approval(cfg)
|
|
return cfg.deployment_id, cfg.operator_pubkey
|
|
|
|
|
|
def approve(
|
|
proposal_id: int,
|
|
actor: Actor,
|
|
cfg: Config | None = None,
|
|
sess=None,
|
|
signature: str | None = None,
|
|
) -> int:
|
|
"""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()
|
|
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": str(exc)},
|
|
)
|
|
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)
|
|
|
|
|
|
def reject(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
|
|
"""Human rejects a proposal. Audited with the operator's identity."""
|
|
return _decide(proposal_id, actor, ProposalStatus.REJECTED, cfg, sess)
|
|
|
|
|
|
def _decide(
|
|
proposal_id: int,
|
|
actor: Actor,
|
|
decision: ProposalStatus,
|
|
cfg: Config | None,
|
|
sess,
|
|
signature: str | None = None,
|
|
) -> int:
|
|
owned = False
|
|
if sess is None:
|
|
outer = session_scope()
|
|
sess = outer.__enter__()
|
|
owned = True
|
|
try:
|
|
proposal = sess.get(Proposal, proposal_id)
|
|
if proposal is None:
|
|
return 1
|
|
if proposal.status != ProposalStatus.PROPOSED:
|
|
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
|
|
audit(
|
|
sess,
|
|
actor=actor,
|
|
action=verb,
|
|
proposal_id=proposal_id,
|
|
result="error",
|
|
detail={"reason": f"invalid state: {proposal.status.value}"},
|
|
)
|
|
return 1
|
|
proposal.status = decision
|
|
detail: dict = {}
|
|
if decision == ProposalStatus.APPROVED:
|
|
proposal.approved_by = actor
|
|
proposal.approved_at = datetime.now(UTC)
|
|
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
|
|
finally:
|
|
if owned:
|
|
outer.__exit__(None, None, None)
|
|
|
|
|
|
# --- autonomous policy (the ONLY non-human approval path, D5) -----------------
|
|
|
|
|
|
def _auto_approve(cfg: Config, sess) -> int:
|
|
"""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))
|
|
.filter(
|
|
AuditEntry.actor == "auto:policy",
|
|
AuditEntry.action == "approve",
|
|
AuditEntry.ts >= midnight,
|
|
)
|
|
.scalar()
|
|
)
|
|
budget = max(0, cfg.autonomous_daily_cap - int(used or 0))
|
|
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.match_score.desc(), Proposal.id.asc())
|
|
.limit(budget)
|
|
.all()
|
|
)
|
|
for proposal in candidates:
|
|
proposal.status = ProposalStatus.APPROVED
|
|
proposal.approved_by = "auto:policy"
|
|
proposal.approved_at = datetime.now(UTC)
|
|
audit(
|
|
sess,
|
|
actor="auto:policy",
|
|
action="approve",
|
|
proposal_id=proposal.id,
|
|
detail={
|
|
"reason": "autonomous_mode policy",
|
|
"kind": proposal.kind,
|
|
"match_score": proposal.match_score,
|
|
"daily_cap": cfg.autonomous_daily_cap,
|
|
},
|
|
)
|
|
approved += 1
|
|
return approved
|
|
|
|
|
|
# --- 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(
|
|
sess,
|
|
actor="daemon:executor",
|
|
action="execute_start",
|
|
proposal_id=proposal.id,
|
|
detail={"plugin": proposal.kind, "attempt": proposal.attempts},
|
|
)
|
|
plugin = plugin_registry.REGISTRY.get(proposal.kind)
|
|
try:
|
|
if plugin is None:
|
|
outcome: dict[str, Any] = plugin_registry.log_action(proposal, cfg, sess)
|
|
outcome["plugin_fallback"] = True
|
|
outcome["note"] = f"no plugin registered for kind {proposal.kind!r}"
|
|
else:
|
|
outcome = plugin(proposal, cfg, sess)
|
|
except Exception as exc: # noqa: BLE001 — plugin boundary: capture for audit
|
|
audit(
|
|
sess,
|
|
actor="daemon:executor",
|
|
action="execute_error",
|
|
proposal_id=proposal.id,
|
|
result="error",
|
|
detail={"error": str(exc)[:300], "attempt": proposal.attempts},
|
|
)
|
|
if proposal.attempts >= cfg.max_attempts:
|
|
proposal.status = ProposalStatus.FAILED
|
|
audit(
|
|
sess,
|
|
actor="daemon:executor",
|
|
action="fail",
|
|
proposal_id=proposal.id,
|
|
result="error",
|
|
detail={"attempts": proposal.attempts},
|
|
)
|
|
else:
|
|
proposal.status = ProposalStatus.APPROVED # retry next run
|
|
return False
|
|
|
|
proposal.status = ProposalStatus.DONE
|
|
audit(
|
|
sess,
|
|
actor="daemon:executor",
|
|
action="execute_done",
|
|
proposal_id=proposal.id,
|
|
detail={"outcome": outcome},
|
|
)
|
|
return True
|
|
|
|
|
|
def run(cfg: Config | None = None, sess=None) -> int:
|
|
"""CLI entry: auto-approve per policy, then execute all approved items."""
|
|
from cayceops.config import load
|
|
from cayceops.db import engine as _engine
|
|
|
|
cfg = cfg or load()
|
|
owned = False
|
|
if sess is None:
|
|
outer = session_scope()
|
|
sess = outer.__enter__()
|
|
owned = True
|
|
try:
|
|
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 = (
|
|
sess.query(Proposal)
|
|
.filter(Proposal.status == ProposalStatus.APPROVED)
|
|
.order_by(Proposal.approved_at.asc())
|
|
.all()
|
|
)
|
|
done = failed = 0
|
|
for proposal in pending:
|
|
if execute_one(proposal, cfg, sess):
|
|
done += 1
|
|
else:
|
|
failed += 1
|
|
run_row.status = "ok"
|
|
run_row.detail = {"auto_approved": auto, "done": done, "failed": failed}
|
|
except Exception as exc: # noqa: BLE001 — daemon boundary
|
|
run_row.status = "error"
|
|
run_row.detail = {"error": str(exc)[:300]}
|
|
run_row.finished_at = datetime.now(UTC)
|
|
return 1
|
|
run_row.finished_at = datetime.now(UTC)
|
|
return 0
|
|
finally:
|
|
if owned:
|
|
outer.__exit__(None, None, None)
|
|
_engine().dispose()
|
|
|
|
|
|
def select_approved(sess) -> list[Proposal]:
|
|
"""Helper for tests/tools: currently approved proposals, oldest first."""
|
|
return list(
|
|
sess.scalars(
|
|
select(Proposal)
|
|
.where(Proposal.status == ProposalStatus.APPROVED)
|
|
.order_by(Proposal.approved_at.asc())
|
|
).all()
|
|
)
|
|
|
|
|
|
ActionPlugin = Callable[[Proposal, Config, Any], dict]
|