Files
cayce 6d77b3eff6
ci / lint-test-leaks (pull_request) Canceled after 0s
audit: AUDIT.md for operator review
Component map + data flow, every external call (where/what/trigger),
human-gate vs AUTONOMOUS_MODE matrix, env+permissions per component,
pinned deps with purpose, known risks (8, each with mitigation),
test+stack instructions. Audited-commit hash lands in the issue.
2026-10-19 21:58:46 +00:00

10 KiB

AUDIT — cayce-ops

Audited commit: the merge of PR #6 on main (see Audited commit at the bottom). Gate that commit passed: make check — ruff (0 findings), 44 tests green, alembic smoke on sqlite, gitleaks full history (no leaks).

1. Component map and data flow

                      CAYCE_OPS_SCANNER_SOURCES (external URLs)
                                │  read-only fetch
                                ▼
   ┌──────────┐  proposals(status=proposed)  ┌──────────┐
   │ scanner  │─────────────────────────────▶│   DB     │
   └──────────┘                              │ (shared) │
                                             │          │        audit_log (every write)
   ┌──────────┐   approved → running →      │          │◀────── actor / action / detail /
   │ executor │◀──── done|failed ───────────│          │        result on every transition
   └──────────┘   plugins by kind            └──────────┘
                                             │
   ┌──────────┐  IMAP inbox ───────────────▶│ mail_messages,
   │ maild    │  drafts (gated)             │ proposals(detail)
   └──────────┘  SMTP only draft=approved   │
                                             │
   ┌──────────┐  content_items ────────────▶│          │──▶ relay (nostr) / Meta (opt-in)
   │publisher │  draft→approved→published   └──────────┘
   └──────────┘
   ┌──────────┐  reads daemon_runs, audit_log, counts
   │ monitor  │────────────────────────────────────▶ status.html (static)
   └──────────┘

Shared schema (cayceops/models.py, migration alembic/versions/0001_initial.py): proposals, audit_log, mail_messages, content_items, daemon_runs.

2. Every external call (where, what it sends, what triggers it)

# Call Code Sends Trigger
1 HTTP GET to scanner sources cayceops/components/scanner/scanner.py → _fetch (urllib) nothing but the request (+ CAYCE_OPS_SCANNER_USER_AGENT header) to URLs configured by the operator cayceops scanner run (cron/systemd timer)
2 IMAP fetch (IMAP4_SSL, UNSEEN) cayceops/components/maild/maild.py → connect_imap, _fetch_unread credentials from env; reads inbox cayceops maild poll
3 SMTP send maild.py → _smtp_send (smtplib, STARTTLS) a reply draft a human approved to the original sender's bare address cayceops maild send-approved, and only rows with draft_status=approved
4 LLM chat completion (openai-compatible) cayceops/llm.py → OpenAICompatProvider.complete (urllib) the email text (truncated 4000 chars) + system prompt; Authorization: Bearer <env key> maild poll only if CAYCE_OPS_LLM_PROVIDER != null (default is null → never)
5 Nostr relay publish (websocket) cayceops/components/publisher/nostr.py → relay_publish/publish a signed NIP-01 event whose content is the human-approved caption cayceops publisher publish-due, channel=nostr, only if key+relays configured
6 Meta Graph API cayceops/components/publisher/adapters.py caption (+optional hosted image URL) + page token refuses to run unless CAYCE_OPS_ENABLE_FACEBOOK/INSTAGRAM=true (default false). Currently unreachable from publish_due.

No other network code exists. Grep guards: urllib.request, imaplib, smtplib, websockets appear only in the files above (verified by review; CI could add a lint rule — see Known risks).

3. What each component can do without a human / with AUTONOMOUS_MODE=true

Component Without any human (default) With CAYCE_OPS_AUTONOMOUS_MODE=true
scanner fetch sources, file proposals, write audit identical (scanner never executes)
executor nothing executes by itself; it acts only on rows a human set to approved (via executor approve --actor …) one added path: auto-approves proposed items whose kind ∈ CAYCE_OPS_AUTONOMOUS_KINDS, capped at CAYCE_OPS_AUTONOMOUS_DAILY_CAP/day, actor auto:policy, fully audited. Then executes those.
maild polls IMAP, classifies, writes drafts. Never sends. identical — autonomy does not touch mail
publisher plans drafts; publishes nothing identical — publishing always requires publisher approve --actor …; autonomy has no effect
monitor reads and renders identical

Explicitly impossible in the current code: sending an unapproved mail draft, publishing unapproved content, executing a non-approved proposal (the single exception is the audited auto:policy path above), disabling the audit write.

4. Environment variables and permissions per component

All config is env-only (cayceops/config.py), prefix CAYCE_OPS_. Full list with placeholders: .env.example.

Component Requires Notes
core DATABASE_URL (postgres or sqlite), LOG_LEVEL, STATE_DIR, MEDIA_DIR DB user needs SELECT/INSERT/UPDATE on the five tables
scanner SCANNER_SOURCES (JSON), SCANNER_KEYWORDS, SCANNER_USER_AGENT outbound GET only
executor AUTONOMOUS_MODE, AUTONOMOUS_KINDS, AUTONOMOUS_DAILY_CAP, MAX_ATTEMPTS, ENABLE_HTTP_ACTIONS http_post plugin stays inert unless enabled
maild IMAP_HOST/PORT/USER/PASSWORD/MAILBOX, SMTP_HOST/PORT/USER/PASSWORD, MAIL_FROM, LLM_PROVIDER/MODEL/BASE_URL/API_KEY/REVIEW_THRESHOLD IMAP user is read+mark-seen; SMTP login can send only as MAIL_FROM
publisher NOSTR_PRIVATE_KEY (32-byte hex), NOSTR_RELAYS, ENABLE_FACEBOOK, FACEBOOK_PAGE_TOKEN, ENABLE_INSTAGRAM, INSTAGRAM_USER_ID nostr key = signing authority; treat as secret
monitor MONITOR_OUTPUT (writable path) needs read on DB, write on output only

Filesystem permissions: default deployment is the cayce user owning /srv/cayce-ops with systemd hardening (NoNewPrivileges, ProtectSystem=strict, ProtectHome=yes) — see deploy/systemd/cayce-ops@.service.

5. Dependencies (pinned in pyproject.toml + uv.lock)

Package Version Why
sqlalchemy 2.0.44 ORM + engine for the shared schema
alembic 1.17.0 production migrations
typer 0.20.0 CLI (all five daemons are CLI commands)
psycopg[binary] 3.2.13 Postgres driver (production stack)
pillow 12.0.0 brand image templates (publisher)
coincurve 21.0.0 libsecp256k1 binding: NIP-01 event signing (publisher)
websockets 15.0.1 nostr relay publish (sync client)
pytest 9.0.2 (dev) test suite
ruff 0.15.1 (dev) lint

stdlib doing heavy lifting: imaplib/smtplib/email (maild), urllib (scanner, LLM, Meta adapters), xml.etree/html.parser (scanner), hashlib/json (nostr ids).

6. Known risks and open items

  1. No live Gitea Actions runner on the current host (DECISIONS D6). CI files ship for GitHub/Gitea Actions, and the same gate runs locally pre-push (scripts/pre-push), but until a runner exists there is no third-party enforcement of the gate on every push.
  2. auto:policy path is real autonomy — bounded (kind allowlist + daily cap + audit) but it does execute without a human in the loop when enabled. The cap counter is DB-based; wiping audit_log resets it.Mitigation: only the operator can set the env flags, and the audit log is append-only by convention.
  3. Executor plugins are in-process Python (D9): no sandboxing. A malicious plugin = arbitrary code. Mitigation: plugins are repo code, reviewed in PRs; the registry starts with log_action fallbacks only.
  4. Mail classification is heuristic (rules; LLM optional). Conservative by design (low confidence → needs_review, unthreaded approvals → review), but a sophisticated phishing email could still be misclassified other. Human gate on sends bounds the damage to drafting wrong replies (never sending them).
  5. Nostr relays are plaintext-config endpoints: a hostile relay sees the note content (public by design) and could replay it; event ids make duplication detectable. No NIP-05, no retries across relay pools yet.
  6. detail JSON columns are schemaless — writers evolve payloads without migrations. Monitor intentionally renders none of it.
  7. gitleaks allowlist covers tests/ (D7): a real secret pasted under tests/ would not trip the scanner. Compensating control: PR review; no fixture reads the environment.
  8. Open: provision an act_runner for Gitea Actions; wire posts_log-style engagement back into publisher planning; add NIP-05 identifier; retries for relay publish.

7. Running tests and the local stack

uv sync --frozen                 # python 3.12 + locked deps
uv run pytest                    # 44 tests, sqlite, no network
make check                       # lint + tests + alembic smoke + gitleaks (pre-push gate)

# full local stack (five daemons + postgres 16):
cp .env.example .env             # fill placeholders; nothing real required for db-only
docker compose up -d db
uv run alembic upgrade head
docker compose up -d             # or run each: uv run cayceops {scanner,executor,maild,publisher,monitor} …

# production path (VPS):
#   see deploy/systemd/README.md (hardened units + timers)

Audited commit

  • Repository: cayce/cayce-ops (branch main)
  • Audited: merge of PR #6 (audit: AUDIT.md for operator review) — hash printed in the "Ready for audit" issue that links this file.
  • Gate at that commit: ruff clean · 44/44 tests · alembic 0001 smoke OK · gitleaks --all no leaks.