Files
cayce-ops/cayceops/config.py
cayce 40453aadb4 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.
2026-10-20 02:20:09 +00:00

181 lines
5.9 KiB
Python

"""Cayce Ops — configuration.
All configuration comes from environment variables prefixed with ``CAYCE_OPS_``.
No config files, no defaults that touch the network. See .env.example for the
full list with placeholder values.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
def _bool(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _int(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None or not raw.strip():
return default
return int(raw)
def _list(name: str, default: list[str] | None = None) -> list[str]:
raw = os.environ.get(name)
if raw is None or not raw.strip():
return list(default or [])
return [item.strip() for item in raw.split(",") if item.strip()]
@dataclass(frozen=True)
class Config:
"""Snapshot of the process environment. Constructed once per CLI invocation."""
# --- core ---
database_url: str = field(
default_factory=lambda: os.environ.get(
"CAYCE_OPS_DATABASE_URL", "sqlite:///cayce-ops.db"
)
)
autonomous_mode: bool = field(
default_factory=lambda: _bool("CAYCE_OPS_AUTONOMOUS_MODE", False)
)
log_level: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_LOG_LEVEL", "INFO")
)
state_dir: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_STATE_DIR", ".")
)
media_dir: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_MEDIA_DIR", "./media")
)
# --- scanner ---
scanner_sources: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_SCANNER_SOURCES", "[]")
)
scanner_keywords: list[str] = field(
default_factory=lambda: _list(
"CAYCE_OPS_SCANNER_KEYWORDS",
["affiliate", "partnership", "bitcoin", "lightning", "travel"],
)
)
scanner_user_agent: str = field(
default_factory=lambda: os.environ.get(
"CAYCE_OPS_SCANNER_USER_AGENT", "cayce-ops-scanner/0.1"
)
)
# --- executor ---
autonomous_kinds: list[str] = field(
default_factory=lambda: _list("CAYCE_OPS_AUTONOMOUS_KINDS", [])
)
autonomous_daily_cap: int = field(
default_factory=lambda: _int("CAYCE_OPS_AUTONOMOUS_DAILY_CAP", 0)
)
max_attempts: int = field(
default_factory=lambda: _int("CAYCE_OPS_MAX_ATTEMPTS", 2)
)
enable_http_actions: bool = field(
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_HTTP_ACTIONS", False)
)
# MANDATORY for approval-gated work: the operator x-only pubkey and the
# deployment identifier are part of every signed approval message.
# Without BOTH, daemons refuse to execute/send/publish — no degraded mode.
operator_pubkey: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_OPERATOR_PUBKEY", "")
)
deployment_id: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_DEPLOYMENT_ID", "")
)
# --- mail ---
imap_host: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_HOST", "")
)
imap_port: int = field(default_factory=lambda: _int("CAYCE_OPS_IMAP_PORT", 993))
imap_user: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_USER", "")
)
imap_password: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_PASSWORD", "")
)
imap_mailbox: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_MAILBOX", "INBOX")
)
smtp_host: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_SMTP_HOST", "")
)
smtp_port: int = field(default_factory=lambda: _int("CAYCE_OPS_SMTP_PORT", 587))
smtp_user: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_SMTP_USER", "")
)
smtp_password: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_SMTP_PASSWORD", "")
)
mail_from: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_MAIL_FROM", "")
)
# --- llm ---
llm_provider: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_LLM_PROVIDER", "null")
)
llm_model: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_LLM_MODEL", "")
)
llm_base_url: str = field(
default_factory=lambda: os.environ.get(
"CAYCE_OPS_LLM_BASE_URL", "https://api.openai.com/v1"
)
)
llm_api_key: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_LLM_API_KEY", "")
)
llm_review_threshold: float = field(
default_factory=lambda: float(
os.environ.get("CAYCE_OPS_LLM_REVIEW_THRESHOLD", "0.7")
)
)
# --- publisher / nostr ---
nostr_private_key: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_NOSTR_PRIVATE_KEY", "")
)
nostr_relays: list[str] = field(
default_factory=lambda: _list("CAYCE_OPS_NOSTR_RELAYS", [])
)
enable_facebook: bool = field(
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_FACEBOOK", False)
)
facebook_page_token: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_FACEBOOK_PAGE_TOKEN", "")
)
enable_instagram: bool = field(
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_INSTAGRAM", False)
)
instagram_user_id: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_INSTAGRAM_USER_ID", "")
)
# --- monitor ---
monitor_output: str = field(
default_factory=lambda: os.environ.get(
"CAYCE_OPS_MONITOR_OUTPUT", "./status.html"
)
)
class ConfigError(RuntimeError):
"""Raised when mandatory configuration is missing."""
def load() -> Config:
"""Build the process Config snapshot."""
return Config()