diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..3e82da8 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,152 @@ +# 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 ` | `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: 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.