Files
cayce-ops/cayceops/config.py
T
cayce 90e6cfcc19 fix: address external audit — criticals 1-5 + mediums
Critical 1 (approval forgery): audit_log is now hash-chained
(prev_hash/entry_hash, verify_audit_chain); executor approve REQUIRES a
BIP-340 signature over sha256(cayce-ops:approve:<id>) when
CAYCE_OPS_OPERATOR_PUBKEY is set; deploy/roles.sql ships least-privilege
per-daemon DB roles.

Critical 2 (threading dead): sends create real OUTBOUND rows (own
Message-ID, In-Reply-To -> inbound); inbound message_id never overwritten;
maild compose adds the first-contact path; _match_proposal falls back to
inbound chains.

Critical 3 (duplicate sends): per-message transactions in send path —
SMTP failure on message N no longer resurrects 1..N-1; failures audited
(send_failed) and the row stays queued.

Critical 4 (classifier): negated approvals (cannot accept / not approved /
not accepting) classify REJECTION at 0.95 before the bare-keyword rule;
LLM prompt braces escaped (.format no longer KeyError into silent fallback).

Critical 5 (systemd): every daemon has a run subcommand
(maild run = poll+send, publisher run = publish-due, monitor run = build);
deploy README documents the separate daily publisher plan timer.

Mediums: approved_hash binds drafts/captions to the approved text (edited
text refuses to send/publish until re-approved); risk_score renamed
match_score (migration 0002) and auto-approve orders HIGHEST relevance
first; compose binds postgres to 127.0.0.1; IMAP marks \\Seen only after
commit (fetch returns (num, raw), mark post-commit); disabled channels are
terminal (ContentStatus.SKIPPED) and publish failures burn a bounded
attempt budget before abandonment; make hooks installs the pre-push gate.

tests: 12 new — 56 total, all green; ruff 0 findings; gitleaks clean.
2026-10-19 23:06:29 +00:00

173 lines
5.6 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)
)
# when set (64-char hex x-only pubkey), human approvals MUST carry a valid
# BIP-340 signature over "cayce-ops:approve:<id>" made by the operator key.
operator_pubkey: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_OPERATOR_PUBKEY", "")
)
# --- 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"
)
)
def load() -> Config:
"""Build the process Config snapshot."""
return Config()