206 lines
14 KiB
Markdown
206 lines
14 KiB
Markdown
# 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 check` at d500d88 to 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
|
|
|
|
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
|
|
|
|
```bash
|
|
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: d500d888701c3f1cd3305d098a448c3e20a8ad99** (merge of PR #7)
|
|
- Gate at that commit: ruff clean (0 findings) · 44/44 tests · alembic 0001
|
|
smoke OK · gitleaks `--all` no leaks · `make check` exit 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.
|