14 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.
Response to external audit — 2026-10-20
External audit verdict reproduced our gate and found 5 critical + 6 medium
issues. All criticals and all mediums are fixed in c9bd441 (PR #9):
| # | Finding | Fix |
|---|---|---|
| C1 | approval forgeable with any --actor |
hash-chained audit_log (verify_audit_chain); optional-required operator BIP-340 signature (CAYCE_OPS_OPERATOR_PUBKEY); deploy/roles.sql least-privilege DB roles |
| C2 | mail threading dead (no OUTBOUND rows, inbound id overwritten, no first-contact) | sends create OUTBOUND rows (own Message-ID + In-Reply-To); inbound id untouched; maild compose; chain fallback in _match_proposal |
| C3 | duplicate sends on SMTP failure | per-message transactions; failures audited (send_failed), row stays queued |
| C4 | negated approvals classified as approvals; LLM prompt .format() KeyError |
negation rules first (0.95 REJECTION); prompt braces escaped |
| C5 | systemd units ran nonexistent run subcommands |
run added to all five daemons; publisher plan timer documented |
| M1-M6 | gate not self-installed; approved text unbound; risk_score misnamed/ordered; postgres 0.0.0.0; IMAP seen-before-commit; publisher noise/infinite retries |
make hooks; approved_hash bindings; match_score (migration 0002) + relevance-desc; 127.0.0.1 bind; \Seen post-commit; SKIPPED terminal + attempt budget |
New audited commit: c9bd441. Gate at that commit: ruff 0 · 56/56 tests ·
alembic 0001→0002 · gitleaks --all clean · make check exit 0.
Still open (declared): apply roles.sql on production, provision CI runner.
Response to audit round 2 — 2026-10-20 (PR #10)
Round-2 audit closed findings 2, 3, 5 and the mediums; the remaining points were re-implemented in PR #10, one commit per point:
| Punto | Hallazgo | Commit | Test que lo prueba |
|---|---|---|---|
| 1a/1b/1c | firmas obligatorias atadas a contenido+deployment, completas en fila y audit | 40453aa audit-1 |
test_signed_approval_mandatory_and_content_bound |
| 1d | verificación al ejecutar/enviar/publicar; SQL-directo se rechaza y revierte | 40453aa audit-1 |
test_sql_approved_row_is_refused_at_execution, test_unsigned_sql_approved_draft_is_refused, test_unsigned_sql_approved_item_not_published |
| 1d | to_addr cambiado tras aprobar bloquea envío | 40453aa audit-1 |
test_changed_to_addr_after_approval_blocks_send |
| 1d | firma de otro deployment/contenido rechazada | 40453aa audit-1 |
test_foreign_deployment_signature_refused (executor+maild), test_foreign_deployment_signature_refused (publisher) |
| 1e | sin ramas if approved_hash and ...: binding obligatorio |
40453aa audit-1 |
cubierto por los tests de firma obligatoria |
| 1f | fallos de firma auditados desde el CLI | 40453aa audit-1 |
test_approve_fails_audited_from_cli_wrapper |
| 2 | clasificador: negación en el mail veta approval → needs_review | 903c591 audit-2 |
test_negated_approvals_never_classify_approval (las 6 oraciones mandadas + 4 extra) |
| 3 | roles.sql: mínimo privilegio por daemon, trigger de aprobación, cayce_approver único, sin contraseñas literales | d774091 audit-3 |
tests/integration/test_pg_roles.py (PG real, ciclo completo por rol) |
| 4 | escrituras serializadas; génesis documentado; anclaje Nostr diario | ffe4253 audit-4 |
test_concurrent_writers_keep_chain_verifiable; legacy-genesis en verify_audit_chain; test_anchor_signs_and_logs_once_per_day |
| 5 | CLI de envío usa el código testeado; fallos auditados + daemon_runs; exit ≠ 0 | 56b35d3 audit-5 (nota: refactor dentro de audit-1, commit marcador) |
test_send_failures_audited_and_exit_code |
| 6 | migraciones upgrade/downgrade contra Postgres 16 en CI | c855f01 audit-6 |
job "Migrations upgrade/downgrade round-trip (Postgres 16)" en ci.yml; ejecutado localmente contra Postgres 18.6 (reportado en PR) |
| 7 | unidad systemd para publisher plan |
3bde785 audit-7 |
deploy/systemd/cayce-ops-plan.{service,timer} (diaria) |
Gate en el commit auditado: ruff 0 · 65 unit tests + 1 integración PG ·
alembic 0001→0003 con round-trip PG · gitleaks --all limpio · make check exit 0.
Declarados abiertos (no resueltos aquí): aplicar roles.sql en producción y
provisionar el runner de CI — decisión y ejecución del operador.