10 KiB
AUDIT — cayce-ops
Audited commit: d500d88 — merge of PR #7 on main.
Note for the auditor: PRs #1-#6 merged with E501 lint findings present (per-branch gate runs were misread). PR #7 is a lint-only cleanup (line wrapping, F841/F821/E731 fixes, no behavior change) that restores a fully green gate. The honest trail is kept here on purpose. Re-run
make checkatd500d88to verify: ruff 0 · 44 tests · no leaks. 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
- 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. auto:policypath 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; wipingaudit_logresets it.Mitigation: only the operator can set the env flags, and the audit log is append-only by convention.- 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_actionfallbacks only. - 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 misclassifiedother. Human gate on sends bounds the damage to drafting wrong replies (never sending them). - 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.
detailJSON columns are schemaless — writers evolve payloads without migrations. Monitor intentionally renders none of it.- gitleaks allowlist covers
tests/(D7): a real secret pasted undertests/would not trip the scanner. Compensating control: PR review; no fixture reads the environment. - 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(branchmain) - Audited:
d500d88870(merge of PR #7) - Gate at that commit: ruff clean (0 findings) · 44/44 tests · alembic 0001
smoke OK · gitleaks
--allno leaks ·make checkexit 0.