Merge PR #9: D16 — desacoplar clave de operador de identidad de agente
ci / lint-test-leaks (push) Canceled after 0s

This commit is contained in:
2026-10-28 19:51:21 +00:00
7 changed files with 202 additions and 3 deletions
+7 -1
View File
@@ -41,14 +41,20 @@ GATE_ERROR = 2 # missing/invalid configuration or signature
def _signing(cfg: Config) -> tuple[str, str]:
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode."""
"""(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
+7 -1
View File
@@ -43,14 +43,20 @@ GATE_ERROR = 2 # missing/invalid configuration or signature
def _signing(cfg: Config) -> tuple[str, str]:
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode."""
"""(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
@@ -118,13 +118,20 @@ 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
+69
View File
@@ -101,3 +101,72 @@ def verify_signature(
)
except (ValueError, TypeError):
return False
def pubkey_from_private_hex(private_key_hex: str) -> str:
"""Derive the 64-char x-only public key from a 32-byte hex private key.
Raises ValueError on malformed input.
"""
if not private_key_hex:
raise ValueError("empty private key")
try:
from coincurve import PublicKey
return PublicKey.from_secret(bytes.fromhex(private_key_hex)).format().hex()[2:]
except (ImportError, ValueError) as exc:
raise ValueError(f"cannot derive pubkey: {exc}") from exc
class SelfApprovalError(RuntimeError):
"""Raised when the configured operator pubkey is the identity of any
key held by the system itself (e.g. Cayce nostr identity). The design
requires a *separate human* operator key — never an agent key."""
FORBIDDEN_IDENTITIES_ENV = "CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS"
def forbidden_identities(cfg) -> list[str]:
"""Pubkeys that may never act as the operator key.
Includes:
1. Any private key loaded from the config (nostr_private_key, or any
future agent identity).
2. An explicit deny-list in CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS
(comma-separated hex pubkeys) for keys held outside this process.
"""
listed: list[str] = []
if cfg.nostr_private_key:
try:
listed.append(pubkey_from_private_hex(cfg.nostr_private_key))
except ValueError:
pass # malformed nostr key: _signing() already catches it elsewhere
import os
raw = os.environ.get(FORBIDDEN_IDENTITIES_ENV, "")
for tok in raw.split(","):
tok = tok.strip().lower()
if len(tok) == 64:
listed.append(tok)
return listed
def validate_no_self_approval(cfg) -> None:
"""Refuse to operate if the operator pubkey equals any identity held by
the system. Raises SelfApprovalError.
Empty operator_pubkey is fine here (that's the 'not configured' state
which _signing() rejects elsewhere); this check is about *wrong* keys.
"""
if not cfg.operator_pubkey:
return
me = cfg.operator_pubkey.strip().lower()
for forbidden in forbidden_identities(cfg):
if me == forbidden:
raise SelfApprovalError(
"CAYCE_OPS_OPERATOR_PUBKEY is an agent identity (Cayce or "
"another in-process key). The operator key must belong to a "
"human and must never reside on this server. Refusing to "
"operate. See docs/DECISIONS.md D16."
)
+27
View File
@@ -117,3 +117,30 @@ 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.
## D16 — La clave del operador nunca es una identidad de agente
**Fecha:** 2026-10-28
**Contexto:** se descubrió que `CAYCE_OPS_OPERATOR_PUBKEY` estaba configurada
con la pubkey de Cayce — es decir, Cayce se aprobaba a sí misma. La firma
BIP-340 era técnicamente válida pero semánticamente nula: "aprobación humana"
firmada por el agente no es aprobación humana.
**Regla:** los daemons y el CLI se niegan a operar si la pubkey del operador
coincide con:
1. La pubkey derivada de cualquier clave privada cargada en la config
(`CAYCE_OPS_NOSTR_PRIVATE_KEY` u otra identidad de agente futura), o
2. Cualquier pubkey en `CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS` (deny-list
explícita, para claves fuera de este proceso).
**Implementación:** `cayceops.signing.validate_no_self_approval()`, llamada
desde `_signing()` en executor/maild/publisher. Lanza `SelfApprovalError`.
**La firma del operador se produce FUERA de la VPS.** El CLI `approve-*`
acepta la firma como parámetro (ya lo hace); nunca la genera él. Mientras
no exista el signer externo, `CAYCE_OPS_OPERATOR_PUBKEY` debe permanecer
vacía — los daemons se niegan a operar y eso es lo correcto.
**La nsec de Cayce NO se toca:** sigue siendo la identidad del agente para
publicar y anclar. Lo que se prohíbe es usarla como clave de operador.
+3 -1
View File
@@ -311,7 +311,9 @@ def test_send_failure_does_not_resurrect_sent_mails(cfg, sess):
p.id, f"desk{i}@acme.example.org", f"[cayce] inquiry {i}", "Hello!", sess
)
assert (
maild._approve_draft(mail_id, "operator", sess, signature=_approve_signed(sess, mail_id))
maild._approve_draft(
mail_id, "operator", sess, signature=_approve_signed(sess, mail_id)
)
== 0
)
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
+82
View File
@@ -0,0 +1,82 @@
"""Tests for D16: the operator key may never be an agent identity.
Background: the operator key was, at some point, Cayce's nostr key. The
daemon must refuse to operate in that configuration, because "human
approval" signed by the agent is not human approval.
"""
from __future__ import annotations
import pytest
from cayceops.signing import (
SelfApprovalError,
pubkey_from_private_hex,
validate_no_self_approval,
)
# keys invented for tests only (allowlisted in .gitleaks.toml)
CAYCE_SECRET = "11" * 32 # simulates Cayce nostr identity
CAYCE_PUBKEY = pubkey_from_private_hex(CAYCE_SECRET)
HUMAN_SECRET = "33" * 32 # a proper human operator key
HUMAN_PUBKEY = pubkey_from_private_hex(HUMAN_SECRET)
def _cfg(monkeypatch, operator_pubkey: str, nostr_private_key: str = CAYCE_SECRET):
from cayceops.config import Config
monkeypatch.setenv("CAYCE_OPS_DEPLOYMENT_ID", "itest")
monkeypatch.setenv("CAYCE_OPS_OPERATOR_PUBKEY", operator_pubkey)
monkeypatch.setenv("CAYCE_OPS_NOSTR_PRIVATE_KEY", nostr_private_key)
return Config()
def test_agent_pubkey_as_operator_is_refused(monkeypatch):
"""The exact bug: operator pubkey = Cayce pubkey must fail."""
cfg = _cfg(monkeypatch, operator_pubkey=CAYCE_PUBKEY)
with pytest.raises(SelfApprovalError):
validate_no_self_approval(cfg)
def test_human_pubkey_passes(monkeypatch):
cfg = _cfg(monkeypatch, operator_pubkey=HUMAN_PUBKEY)
validate_no_self_approval(cfg) # does not raise
def test_empty_operator_pubkey_passes_validation(monkeypatch):
"""Empty is the 'not configured' state — rejected elsewhere, not here."""
cfg = _cfg(monkeypatch, operator_pubkey="")
validate_no_self_approval(cfg)
def test_explicit_denylist_is_honoured(monkeypatch):
"""A key held outside this process can be listed explicitly."""
outside_pubkey = pubkey_from_private_hex("44" * 32)
monkeypatch.setenv(
"CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS", f"{outside_pubkey}, {HUMAN_PUBKEY}"
)
cfg = _cfg(monkeypatch, operator_pubkey=HUMAN_PUBKEY, nostr_private_key="")
with pytest.raises(SelfApprovalError):
validate_no_self_approval(cfg)
def test_daemon_refuses_to_start_with_agent_key(monkeypatch, sess, db_engine):
"""End-to-end: executor.run must refuse (exit 2) when the operator
pubkey is Cayce's identity."""
from cayceops.components.executor import executor
from cayceops.models import DaemonRun
cfg = _cfg(monkeypatch, operator_pubkey=CAYCE_PUBKEY)
code = executor.run(cfg, sess)
assert code == 2
run_row = sess.query(DaemonRun).filter_by(daemon="executor").one()
assert run_row.status == "error"
assert "agent identity" in run_row.detail["refused"]
def test_pubkey_derivation_roundtrip():
assert pubkey_from_private_hex(HUMAN_SECRET) == HUMAN_PUBKEY
with pytest.raises(ValueError):
pubkey_from_private_hex("")
with pytest.raises(ValueError):
pubkey_from_private_hex("nothex")