audit-D16: operator key must never be an agent identity
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).
This commit is contained in:
2026-10-28 19:51:04 +00:00
parent 53ea15ee51
commit 0d24309433
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")