Compare commits
25
Commits
skeleton
...
audit-round2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bde785261 | ||
|
|
c855f01da5 | ||
|
|
56b35d3a63 | ||
|
|
ffe425320d | ||
|
|
d77409148c | ||
|
|
903c591a06 | ||
|
|
40453aadb4 | ||
|
|
61d5cfd67d | ||
|
|
c9bd4411e8 | ||
|
|
90e6cfcc19 | ||
|
|
2da5f3ff24 | ||
|
|
2423cd10b8 | ||
|
|
d500d88870 | ||
|
|
a282aedb94 | ||
|
|
abbaf7e4e2 | ||
|
|
6d77b3eff6 | ||
|
|
3f07362b7a | ||
|
|
f61372e28e | ||
|
|
25c7db3a31 | ||
|
|
aabd6bafc7 | ||
|
|
15072a0cd2 | ||
|
|
5b259bc663 | ||
|
|
1b2c3cc1cf | ||
|
|
73ccdf7bdf | ||
|
|
00fa3777ab |
@@ -21,6 +21,9 @@ CAYCE_OPS_AUTONOMOUS_KINDS=
|
||||
CAYCE_OPS_AUTONOMOUS_DAILY_CAP=0
|
||||
CAYCE_OPS_MAX_ATTEMPTS=2
|
||||
CAYCE_OPS_ENABLE_HTTP_ACTIONS=false
|
||||
# when set (64-char hex x-only pubkey), executor approve REQUIRES a BIP-340
|
||||
# signature over sha256("cayce-ops:approve:<id>") made by the operator key
|
||||
CAYCE_OPS_OPERATOR_PUBKEY=
|
||||
|
||||
# --- mail (IMAP read / SMTP send-approved-drafts-only) ---
|
||||
CAYCE_OPS_IMAP_HOST=imap.example.org
|
||||
|
||||
+31
-3
@@ -8,6 +8,21 @@ on:
|
||||
jobs:
|
||||
lint-test-leaks:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: cayce
|
||||
POSTGRES_PASSWORD: ci-only-not-a-secret
|
||||
POSTGRES_DB: cayce_ops
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U cayce -d cayce_ops"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||
env:
|
||||
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops
|
||||
PGPASSWORD: ci-only-not-a-secret
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -23,11 +38,24 @@ jobs:
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Migrations smoke test (SQLite)
|
||||
- name: Migrations upgrade/downgrade round-trip (Postgres 16)
|
||||
run: |
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade 0001
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade base
|
||||
uv run alembic upgrade head
|
||||
|
||||
- name: Alembic smoke (SQLite)
|
||||
run: uv run alembic upgrade head
|
||||
|
||||
- name: Tests (pytest)
|
||||
run: uv run pytest
|
||||
- name: Unit tests (pytest)
|
||||
run: uv run pytest -m "not integration"
|
||||
|
||||
- name: Integration tests — roles.sql + daemon cycles (Postgres 16)
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
uv run pytest -m integration -v
|
||||
|
||||
- name: Secret scan (gitleaks, full history)
|
||||
run: |
|
||||
|
||||
@@ -8,6 +8,21 @@ on:
|
||||
jobs:
|
||||
lint-test-leaks:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: cayce
|
||||
POSTGRES_PASSWORD: ci-only-not-a-secret
|
||||
POSTGRES_DB: cayce_ops
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U cayce -d cayce_ops"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||
env:
|
||||
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops
|
||||
PGPASSWORD: ci-only-not-a-secret
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -23,11 +38,24 @@ jobs:
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Migrations smoke test (SQLite)
|
||||
- name: Migrations upgrade/downgrade round-trip (Postgres 16)
|
||||
run: |
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade 0001
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade base
|
||||
uv run alembic upgrade head
|
||||
|
||||
- name: Alembic smoke (SQLite)
|
||||
run: uv run alembic upgrade head
|
||||
|
||||
- name: Tests (pytest)
|
||||
run: uv run pytest
|
||||
- name: Unit tests (pytest)
|
||||
run: uv run pytest -m "not integration"
|
||||
|
||||
- name: Integration tests — roles.sql + daemon cycles (Postgres 16)
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
uv run pytest -m integration -v
|
||||
|
||||
- name: Secret scan (gitleaks, full history)
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# 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.
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: check lint test migrate-check leaks sync
|
||||
.PHONY: check lint test migrate-check leaks sync hooks
|
||||
|
||||
sync: ## install locked deps (creates .venv)
|
||||
uv sync --frozen
|
||||
@@ -17,5 +17,9 @@ migrate-check: ## smoke the alembic chain on a scratch sqlite db
|
||||
leaks: ## gitleaks over the full history
|
||||
gitleaks detect --source . --log-opts="--all" -v
|
||||
|
||||
hooks: ## install the pre-push gate as a git hook (part of setup)
|
||||
git config core.hooksPath scripts/githooks
|
||||
@echo "pre-push gate installed (scripts/githooks/pre-push)"
|
||||
|
||||
check: lint test migrate-check leaks ## the pre-push gate — must be green before every push
|
||||
@echo "check: OK"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""audit hardening migration — minimal standalone mirror of 0002.
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
OLD_CONTENT_STATUSES = ("DRAFT", "APPROVED", "PUBLISHED")
|
||||
NEW_CONTENT_STATUSES = ("DRAFT", "APPROVED", "PUBLISHED", "SKIPPED")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("audit_log") as batch:
|
||||
batch.add_column(sa.Column("prev_hash", sa.String(length=64), nullable=True))
|
||||
batch.add_column(sa.Column("entry_hash", sa.String(length=64), nullable=True))
|
||||
batch.create_index("ix_audit_log_entry_hash", ["entry_hash"])
|
||||
|
||||
with op.batch_alter_table("proposals") as batch:
|
||||
batch.alter_column("risk_score", new_column_name="match_score")
|
||||
|
||||
with op.batch_alter_table("mail_messages") as batch:
|
||||
batch.add_column(sa.Column("approved_hash", sa.String(length=64), nullable=True))
|
||||
batch.add_column(sa.Column("approved_by", sa.String(length=128), nullable=True))
|
||||
|
||||
if op.get_bind().dialect.name == "postgresql":
|
||||
op.execute("ALTER TYPE contentstatus ADD VALUE IF NOT EXISTS 'SKIPPED'")
|
||||
with op.batch_alter_table("content_items") as batch:
|
||||
batch.alter_column(
|
||||
"status",
|
||||
existing_type=sa.Enum(*OLD_CONTENT_STATUSES, name="contentstatus"),
|
||||
type_=sa.Enum(*NEW_CONTENT_STATUSES, name="contentstatus"),
|
||||
existing_nullable=False,
|
||||
)
|
||||
batch.add_column(sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"))
|
||||
batch.add_column(sa.Column("approved_hash", sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("content_items") as batch:
|
||||
batch.drop_column("approved_hash")
|
||||
batch.drop_column("attempts")
|
||||
batch.alter_column(
|
||||
"status",
|
||||
existing_type=sa.Enum(*NEW_CONTENT_STATUSES, name="contentstatus"),
|
||||
type_=sa.Enum(*OLD_CONTENT_STATUSES, name="contentstatus"),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
with op.batch_alter_table("mail_messages") as batch:
|
||||
batch.drop_column("approved_by")
|
||||
batch.drop_column("approved_hash")
|
||||
|
||||
with op.batch_alter_table("proposals") as batch:
|
||||
batch.alter_column("match_score", new_column_name="risk_score")
|
||||
|
||||
with op.batch_alter_table("audit_log") as batch:
|
||||
batch.drop_index("ix_audit_log_entry_hash")
|
||||
batch.drop_column("entry_hash")
|
||||
batch.drop_column("prev_hash")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""approval hardening: mandatory operator signatures bound to content
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-10-20
|
||||
|
||||
- proposals.operator_signature
|
||||
- mail_messages.operator_signature
|
||||
- content_items.operator_signature
|
||||
|
||||
Full signatures (128 hex chars) — never truncated. Existing NULL rows are
|
||||
refused at execution/send/publish time by the signing gate (no degraded mode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("proposals") as batch:
|
||||
batch.add_column(sa.Column("operator_signature", sa.Text(), nullable=True))
|
||||
with op.batch_alter_table("mail_messages") as batch:
|
||||
batch.add_column(sa.Column("operator_signature", sa.Text(), nullable=True))
|
||||
with op.batch_alter_table("content_items") as batch:
|
||||
batch.add_column(sa.Column("operator_signature", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("content_items") as batch:
|
||||
batch.drop_column("operator_signature")
|
||||
with op.batch_alter_table("mail_messages") as batch:
|
||||
batch.drop_column("operator_signature")
|
||||
with op.batch_alter_table("proposals") as batch:
|
||||
batch.drop_column("operator_signature")
|
||||
+73
-10
@@ -71,10 +71,16 @@ def executor_run() -> None:
|
||||
|
||||
@executor_app.command("approve")
|
||||
def executor_approve(
|
||||
proposal_id: int, actor: str = typer.Option(..., help="Human approver id")
|
||||
proposal_id: int,
|
||||
actor: str = typer.Option(..., help="Human approver id"),
|
||||
signature: str = typer.Option(
|
||||
..., help="BIP-340 sig over the approval message (mandatory)"
|
||||
),
|
||||
) -> None:
|
||||
"""Approve a proposal (human action, audited)."""
|
||||
raise typer.Exit(_load_component("executor").approve(proposal_id, actor))
|
||||
"""Approve a proposal (human action, audited; signature mandatory)."""
|
||||
raise typer.Exit(
|
||||
_load_component("executor").approve(proposal_id, actor, signature=signature)
|
||||
)
|
||||
|
||||
|
||||
@executor_app.command("reject")
|
||||
@@ -95,17 +101,48 @@ def maild_poll() -> None:
|
||||
|
||||
|
||||
@maild_app.command("approve-draft")
|
||||
def maild_approve(msg_id: int, actor: str = typer.Option(...)) -> None:
|
||||
"""Approve a drafted reply so SMTP may send it."""
|
||||
raise typer.Exit(_load_component("maild").approve_draft(msg_id, actor))
|
||||
def maild_approve(
|
||||
msg_id: int,
|
||||
actor: str = typer.Option(...),
|
||||
signature: str = typer.Option(
|
||||
..., help="BIP-340 sig over the approval message (mandatory)"
|
||||
),
|
||||
) -> None:
|
||||
"""Approve a drafted reply so SMTP may send it (signature mandatory)."""
|
||||
raise typer.Exit(
|
||||
_load_component("maild").approve_draft(msg_id, actor, signature)
|
||||
)
|
||||
|
||||
|
||||
@maild_app.command("send-approved")
|
||||
def maild_send() -> None:
|
||||
"""Send all approved drafts via SMTP."""
|
||||
"""Send all approved drafts via SMTP (per-message commit)."""
|
||||
raise typer.Exit(_load_component("maild").send_approved())
|
||||
|
||||
|
||||
@maild_app.command("compose")
|
||||
def maild_compose(
|
||||
proposal_id: int,
|
||||
to_addr: str = typer.Option(...),
|
||||
subject: str = typer.Option(...),
|
||||
body: str = typer.Option(...),
|
||||
) -> None:
|
||||
"""Create the FIRST-CONTACT outbound draft for a proposal (drafted)."""
|
||||
raise typer.Exit(
|
||||
_load_component("maild").compose(proposal_id, to_addr, subject, body)
|
||||
)
|
||||
|
||||
|
||||
@maild_app.command("run")
|
||||
def maild_run() -> None:
|
||||
"""One daemon cycle: poll inbound, then send approved drafts."""
|
||||
comp = _load_component("maild")
|
||||
code = comp.poll()
|
||||
if code == 0:
|
||||
code = comp.send_approved()
|
||||
raise typer.Exit(code)
|
||||
|
||||
|
||||
# --- publisher ---------------------------------------------------------------
|
||||
publisher_app = typer.Typer(name="publisher", help="Themed content calendar")
|
||||
app.add_typer(publisher_app)
|
||||
@@ -124,9 +161,17 @@ def publisher_image(item_id: int) -> None:
|
||||
|
||||
|
||||
@publisher_app.command("approve")
|
||||
def publisher_approve(item_id: int, actor: str = typer.Option(...)) -> None:
|
||||
"""Approve a draft content item."""
|
||||
raise typer.Exit(_load_component("publisher").approve(item_id, actor))
|
||||
def publisher_approve(
|
||||
item_id: int,
|
||||
actor: str = typer.Option(...),
|
||||
signature: str = typer.Option(
|
||||
..., help="BIP-340 sig over the approval message (mandatory)"
|
||||
),
|
||||
) -> None:
|
||||
"""Approve a draft content item (signature mandatory)."""
|
||||
raise typer.Exit(
|
||||
_load_component("publisher").approve(item_id, actor, signature)
|
||||
)
|
||||
|
||||
|
||||
@publisher_app.command("publish-due")
|
||||
@@ -135,6 +180,12 @@ def publisher_publish() -> None:
|
||||
raise typer.Exit(_load_component("publisher").publish_due())
|
||||
|
||||
|
||||
@publisher_app.command("run")
|
||||
def publisher_run() -> None:
|
||||
"""One daemon cycle: publish due approved items (plan is cron'd separately)."""
|
||||
raise typer.Exit(_load_component("publisher").publish_due())
|
||||
|
||||
|
||||
# --- monitor -----------------------------------------------------------------
|
||||
monitor_app = typer.Typer(name="monitor", help="Static status page")
|
||||
app.add_typer(monitor_app)
|
||||
@@ -146,6 +197,18 @@ def monitor_build() -> None:
|
||||
raise typer.Exit(_load_component("monitor").build())
|
||||
|
||||
|
||||
@monitor_app.command("run")
|
||||
def monitor_run() -> None:
|
||||
"""One daemon cycle: build the status page."""
|
||||
raise typer.Exit(_load_component("monitor").build())
|
||||
|
||||
|
||||
@monitor_app.command("anchor")
|
||||
def monitor_anchor() -> None:
|
||||
"""Publish today's audit-chain anchor as a signed Nostr note (1/day)."""
|
||||
raise typer.Exit(_load_component("monitor").anchor())
|
||||
|
||||
|
||||
def main() -> None: # pragma: no cover
|
||||
app()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""executor package."""
|
||||
@@ -0,0 +1,402 @@
|
||||
"""executor — executes APPROVED proposals through registered action plugins.
|
||||
|
||||
Guarantees
|
||||
----------
|
||||
- Only ``approved`` proposals execute (``AUTONOMOUS_MODE=false`` default).
|
||||
- With ``AUTONOMOUS_MODE=true`` the executor gains exactly one capability:
|
||||
bounded auto-approval of ``proposed`` items whose ``kind`` is in
|
||||
``CAYCE_OPS_AUTONOMOUS_KINDS``, capped by ``CAYCE_OPS_AUTONOMOUS_DAILY_CAP``,
|
||||
recorded with actor ``auto:policy`` (DECISIONS D5).
|
||||
- Every transition writes an ``audit_log`` row: who approved, what ran,
|
||||
plugin output, success/failure.
|
||||
- Unknown proposal kinds fall back to the ``log_action`` plugin (never a
|
||||
network side effect). See DECISIONS D9.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from cayceops.components.executor import plugins as plugin_registry
|
||||
from cayceops.config import Config
|
||||
from cayceops.db import session_scope
|
||||
from cayceops.models import AuditEntry, DaemonRun, Proposal, ProposalStatus, audit
|
||||
|
||||
Actor = str
|
||||
|
||||
|
||||
def _load_cfg():
|
||||
from cayceops.config import load
|
||||
|
||||
return load()
|
||||
|
||||
|
||||
# --- human transitions -------------------------------------------------------
|
||||
|
||||
GATE_ERROR = 2 # missing/invalid configuration or signature
|
||||
|
||||
|
||||
def _signing(cfg: Config) -> tuple[str, str]:
|
||||
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode."""
|
||||
from cayceops.config import ConfigError
|
||||
|
||||
if not cfg.operator_pubkey or not cfg.deployment_id:
|
||||
raise ConfigError(
|
||||
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
|
||||
"CAYCE_OPS_DEPLOYMENT_ID are both required"
|
||||
)
|
||||
return cfg.deployment_id, cfg.operator_pubkey
|
||||
|
||||
|
||||
def approve(
|
||||
proposal_id: int,
|
||||
actor: Actor,
|
||||
cfg: Config | None = None,
|
||||
sess=None,
|
||||
signature: str | None = None,
|
||||
) -> int:
|
||||
"""Human approves a proposal. Signature is MANDATORY and bound to the
|
||||
proposal's current content and this deployment (audit finding 1)."""
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.signing import (
|
||||
SignatureError,
|
||||
proposal_content_hash,
|
||||
verify_signature,
|
||||
)
|
||||
|
||||
cfg = cfg or _load_cfg()
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except ConfigError as exc:
|
||||
if sess is not None:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve",
|
||||
proposal_id=proposal_id,
|
||||
result="error",
|
||||
detail={"reason": str(exc)},
|
||||
)
|
||||
return GATE_ERROR
|
||||
|
||||
proposal = sess.get(Proposal, proposal_id) if sess is not None else None
|
||||
if proposal is None:
|
||||
return 1
|
||||
chash = proposal_content_hash(
|
||||
proposal.kind, proposal.title, proposal.url, proposal.detail
|
||||
)
|
||||
try:
|
||||
if not signature:
|
||||
raise SignatureError("signature required")
|
||||
if not verify_signature(
|
||||
"proposal", deployment_id, proposal_id, chash, signature, pubkey
|
||||
):
|
||||
raise SignatureError("signature invalid for this content/deployment")
|
||||
except SignatureError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve",
|
||||
proposal_id=proposal_id,
|
||||
result="error",
|
||||
detail={"reason": str(exc), "content_hash": chash},
|
||||
)
|
||||
return GATE_ERROR
|
||||
|
||||
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess, signature)
|
||||
|
||||
|
||||
def reject(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
|
||||
"""Human rejects a proposal. Audited with the operator's identity."""
|
||||
return _decide(proposal_id, actor, ProposalStatus.REJECTED, cfg, sess)
|
||||
|
||||
|
||||
def _decide(
|
||||
proposal_id: int,
|
||||
actor: Actor,
|
||||
decision: ProposalStatus,
|
||||
cfg: Config | None,
|
||||
sess,
|
||||
signature: str | None = None,
|
||||
) -> int:
|
||||
owned = False
|
||||
if sess is None:
|
||||
outer = session_scope()
|
||||
sess = outer.__enter__()
|
||||
owned = True
|
||||
try:
|
||||
proposal = sess.get(Proposal, proposal_id)
|
||||
if proposal is None:
|
||||
return 1
|
||||
if proposal.status != ProposalStatus.PROPOSED:
|
||||
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action=verb,
|
||||
proposal_id=proposal_id,
|
||||
result="error",
|
||||
detail={"reason": f"invalid state: {proposal.status.value}"},
|
||||
)
|
||||
return 1
|
||||
proposal.status = decision
|
||||
detail: dict = {}
|
||||
if decision == ProposalStatus.APPROVED:
|
||||
proposal.approved_by = actor
|
||||
proposal.approved_at = datetime.now(UTC)
|
||||
proposal.operator_signature = signature # FULL signature, row + audit
|
||||
detail["signature"] = signature or ""
|
||||
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
|
||||
audit(sess, actor=actor, action=verb, proposal_id=proposal_id, detail=detail)
|
||||
return 0
|
||||
finally:
|
||||
if owned:
|
||||
outer.__exit__(None, None, None)
|
||||
|
||||
|
||||
# --- autonomous policy (the ONLY non-human approval path, D5) -----------------
|
||||
|
||||
|
||||
def _auto_approve(cfg: Config, sess) -> int:
|
||||
"""DISABLED (audit round 2, point 1a): approvals require operator
|
||||
signatures bound to content — there is no non-human approval path.
|
||||
AUTONOMOUS_MODE no longer grants approval powers (DECISIONS D13)."""
|
||||
if cfg.autonomous_mode and cfg.autonomous_kinds:
|
||||
audit(
|
||||
sess,
|
||||
actor="auto:policy",
|
||||
action="autonomy_disabled",
|
||||
result="error",
|
||||
detail={
|
||||
"reason": "mandatory operator signatures: no non-human approvals",
|
||||
},
|
||||
)
|
||||
return 0
|
||||
midnight = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
used = (
|
||||
sess.query(func.count(AuditEntry.id))
|
||||
.filter(
|
||||
AuditEntry.actor == "auto:policy",
|
||||
AuditEntry.action == "approve",
|
||||
AuditEntry.ts >= midnight,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
budget = max(0, cfg.autonomous_daily_cap - int(used or 0))
|
||||
approved = 0
|
||||
if budget == 0:
|
||||
return 0
|
||||
# most relevant first (match_score = keyword relevance, NOT risk — the
|
||||
# audit's naming finding; ascending order approved noise first)
|
||||
candidates = (
|
||||
sess.query(Proposal)
|
||||
.filter(
|
||||
Proposal.status == ProposalStatus.PROPOSED,
|
||||
Proposal.kind.in_(cfg.autonomous_kinds),
|
||||
)
|
||||
.order_by(Proposal.match_score.desc(), Proposal.id.asc())
|
||||
.limit(budget)
|
||||
.all()
|
||||
)
|
||||
for proposal in candidates:
|
||||
proposal.status = ProposalStatus.APPROVED
|
||||
proposal.approved_by = "auto:policy"
|
||||
proposal.approved_at = datetime.now(UTC)
|
||||
audit(
|
||||
sess,
|
||||
actor="auto:policy",
|
||||
action="approve",
|
||||
proposal_id=proposal.id,
|
||||
detail={
|
||||
"reason": "autonomous_mode policy",
|
||||
"kind": proposal.kind,
|
||||
"match_score": proposal.match_score,
|
||||
"daily_cap": cfg.autonomous_daily_cap,
|
||||
},
|
||||
)
|
||||
approved += 1
|
||||
return approved
|
||||
|
||||
|
||||
# --- execution ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _verify_approval_signature(proposal: Proposal, cfg: Config, sess) -> bool:
|
||||
"""Run-time gate (audit round 2, 1d): a row in `approved` MUST carry a
|
||||
valid operator signature for its CURRENT content and THIS deployment.
|
||||
Rows approved by raw SQL (no signature) or edited after approval are
|
||||
rejected, audited as error, and revert to `proposed`."""
|
||||
from cayceops.signing import (
|
||||
SignatureError,
|
||||
proposal_content_hash,
|
||||
verify_signature,
|
||||
)
|
||||
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except Exception as exc: # noqa: BLE001 — ConfigError at daemon boundary
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_refused",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={"reason": str(exc)},
|
||||
)
|
||||
return False
|
||||
chash = proposal_content_hash(
|
||||
proposal.kind, proposal.title, proposal.url, proposal.detail
|
||||
)
|
||||
try:
|
||||
if not verify_signature(
|
||||
"proposal",
|
||||
deployment_id,
|
||||
proposal.id,
|
||||
chash,
|
||||
proposal.operator_signature,
|
||||
pubkey,
|
||||
):
|
||||
raise SignatureError(
|
||||
"no valid operator signature for current content/deployment"
|
||||
)
|
||||
except SignatureError as exc:
|
||||
proposal.status = ProposalStatus.PROPOSED # back to pre-approval
|
||||
proposal.operator_signature = None
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_refused",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={
|
||||
"reason": str(exc),
|
||||
"content_hash": chash,
|
||||
"had_signature": bool(proposal.operator_signature),
|
||||
},
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def execute_one(proposal: Proposal, cfg: Config, sess) -> bool:
|
||||
"""Run the plugin for one approved proposal. Returns True on success."""
|
||||
if not _verify_approval_signature(proposal, cfg, sess):
|
||||
return False
|
||||
proposal.status = ProposalStatus.RUNNING
|
||||
proposal.attempts += 1
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_start",
|
||||
proposal_id=proposal.id,
|
||||
detail={"plugin": proposal.kind, "attempt": proposal.attempts},
|
||||
)
|
||||
plugin = plugin_registry.REGISTRY.get(proposal.kind)
|
||||
try:
|
||||
if plugin is None:
|
||||
outcome: dict[str, Any] = plugin_registry.log_action(proposal, cfg, sess)
|
||||
outcome["plugin_fallback"] = True
|
||||
outcome["note"] = f"no plugin registered for kind {proposal.kind!r}"
|
||||
else:
|
||||
outcome = plugin(proposal, cfg, sess)
|
||||
except Exception as exc: # noqa: BLE001 — plugin boundary: capture for audit
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_error",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={"error": str(exc)[:300], "attempt": proposal.attempts},
|
||||
)
|
||||
if proposal.attempts >= cfg.max_attempts:
|
||||
proposal.status = ProposalStatus.FAILED
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="fail",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={"attempts": proposal.attempts},
|
||||
)
|
||||
else:
|
||||
proposal.status = ProposalStatus.APPROVED # retry next run
|
||||
return False
|
||||
|
||||
proposal.status = ProposalStatus.DONE
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_done",
|
||||
proposal_id=proposal.id,
|
||||
detail={"outcome": outcome},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def run(cfg: Config | None = None, sess=None) -> int:
|
||||
"""CLI entry: auto-approve per policy, then execute all approved items."""
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine as _engine
|
||||
|
||||
cfg = cfg or load()
|
||||
owned = False
|
||||
if sess is None:
|
||||
outer = session_scope()
|
||||
sess = outer.__enter__()
|
||||
owned = True
|
||||
try:
|
||||
run_row = DaemonRun(daemon="executor")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
_signing(cfg) # refuse to run without signing config (no degraded mode)
|
||||
except Exception as exc: # noqa: BLE001 — ConfigError at boundary
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"refused": str(exc)[:200]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 2
|
||||
try:
|
||||
auto = _auto_approve(cfg, sess)
|
||||
pending = (
|
||||
sess.query(Proposal)
|
||||
.filter(Proposal.status == ProposalStatus.APPROVED)
|
||||
.order_by(Proposal.approved_at.asc())
|
||||
.all()
|
||||
)
|
||||
done = failed = 0
|
||||
for proposal in pending:
|
||||
if execute_one(proposal, cfg, sess):
|
||||
done += 1
|
||||
else:
|
||||
failed += 1
|
||||
run_row.status = "ok"
|
||||
run_row.detail = {"auto_approved": auto, "done": done, "failed": failed}
|
||||
except Exception as exc: # noqa: BLE001 — daemon boundary
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"error": str(exc)[:300]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 1
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 0
|
||||
finally:
|
||||
if owned:
|
||||
outer.__exit__(None, None, None)
|
||||
_engine().dispose()
|
||||
|
||||
|
||||
def select_approved(sess) -> list[Proposal]:
|
||||
"""Helper for tests/tools: currently approved proposals, oldest first."""
|
||||
return list(
|
||||
sess.scalars(
|
||||
select(Proposal)
|
||||
.where(Proposal.status == ProposalStatus.APPROVED)
|
||||
.order_by(Proposal.approved_at.asc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
ActionPlugin = Callable[[Proposal, Config, Any], dict]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""executor action plugins.
|
||||
|
||||
A plugin maps a proposal ``kind`` to a side effect. Plugins are repo code —
|
||||
they are reviewed like everything else and must:
|
||||
|
||||
- take ``(proposal, cfg, sess)`` and return a JSON-safe dict (the audit outcome)
|
||||
- do their own permission checks (see ``http_post`` + ENABLE_HTTP_ACTIONS)
|
||||
- raise on failure (the executor captures the exception into the audit log)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from cayceops.config import Config
|
||||
from cayceops.models import Proposal, audit
|
||||
|
||||
REGISTRY: dict[str, Any] = {}
|
||||
|
||||
|
||||
def register(kind: str):
|
||||
"""Decorator: register an action plugin for a proposal kind."""
|
||||
|
||||
def deco(fn):
|
||||
REGISTRY[kind] = fn
|
||||
return fn
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
@register("log_action")
|
||||
def log_action(proposal: Proposal, cfg: Config, sess) -> dict:
|
||||
"""No-side-effect plugin: record the execution in the audit log only."""
|
||||
return {"action": "logged", "title": proposal.title[:200]}
|
||||
|
||||
|
||||
@register("http_post")
|
||||
def http_post(proposal: Proposal, cfg: Config, sess) -> dict:
|
||||
"""POST the proposal payload to a webhook.
|
||||
|
||||
Inert unless ``CAYCE_OPS_ENABLE_HTTP_ACTIONS=true``. Target URL comes from
|
||||
the proposal detail (``detail['webhook_url']``) — operator-controlled data.
|
||||
"""
|
||||
if not cfg.enable_http_actions:
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="http_post_blocked",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={"reason": "CAYCE_OPS_ENABLE_HTTP_ACTIONS is false"},
|
||||
)
|
||||
raise PermissionError("http_post disabled (CAYCE_OPS_ENABLE_HTTP_ACTIONS=false)")
|
||||
|
||||
url = (proposal.detail or {}).get("webhook_url", "")
|
||||
if not url:
|
||||
raise ValueError("proposal detail.webhook_url missing")
|
||||
payload = json.dumps(
|
||||
{"proposal_id": proposal.id, "kind": proposal.kind, "title": proposal.title}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=20.0) as resp: # noqa: S310
|
||||
return {"action": "http_post", "status": resp.status}
|
||||
|
||||
|
||||
# proposals from the scanner default to content_gap/affiliate/partnership kinds;
|
||||
# until dedicated plugins exist for those kinds they must not do anything
|
||||
# network-visible. register more plugins here as the operator approves them.
|
||||
for _safe_kind in ("affiliate", "partnership", "content_gap", "other"):
|
||||
REGISTRY.setdefault(_safe_kind, log_action)
|
||||
@@ -0,0 +1 @@
|
||||
"""maild package."""
|
||||
@@ -0,0 +1,108 @@
|
||||
"""maild classification: rules + sentence-level negation, LLM optional.
|
||||
|
||||
Approval rule (audit round 2, point 2): a mail may only classify `approval`
|
||||
when NO sentence contains a negation. Any negation token anywhere in the mail
|
||||
(conservative: even outside the approval sentence) downgrades an approval to
|
||||
`needs_review`. Rejections/questions/other are unaffected.
|
||||
|
||||
The LLM never decides alone: below-threshold confidence → `needs_review`.
|
||||
With the `null` provider (default) only deterministic rules run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from cayceops.config import Config
|
||||
from cayceops.models import MailClassification
|
||||
|
||||
_APPROVAL_WORDS = re.compile(r"\b(approv|accept|welcome)", re.I)
|
||||
|
||||
# any of these tokens anywhere in the mail vetoes an `approval` result
|
||||
_NEGATIONS = re.compile(
|
||||
r"\b(not|no|never|cannot|can'?t|won'?t|hasn'?t|isn'?t|"
|
||||
r"no\s+longer|unable|decline\w*|regret\w*|unfortunately)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
|
||||
# (class, pattern, confidence when matched)
|
||||
(
|
||||
MailClassification.REJECTION,
|
||||
# noqa: E501 (kept on one line: the pattern IS the reviewed artifact)
|
||||
re.compile(r"\b(declin|reject|not able to|unable to|we regret|won.t be able)\w*", re.I), # noqa: E501
|
||||
0.9,
|
||||
),
|
||||
(
|
||||
MailClassification.APPROVAL,
|
||||
re.compile(r"\b(approv|accept|welcome to the|you.re (in|approved)|live|activated|glad to)\w*", re.I), # noqa: E501
|
||||
0.85,
|
||||
),
|
||||
(
|
||||
MailClassification.QUESTION,
|
||||
re.compile(r"\b(could you|can you|please (provide|clarify|confirm)|what (is|are)|how (do|does|can)|need more (info|details))\b", re.I), # noqa: E501
|
||||
0.7,
|
||||
),
|
||||
]
|
||||
|
||||
_LLM_PROMPT = """Classify this merchant email into exactly one of:
|
||||
approval, rejection, question, other.
|
||||
Reply with strict JSON: {{"classification": "...", "confidence": 0.0-1.0}}
|
||||
|
||||
Subject and body:
|
||||
{body}"""
|
||||
|
||||
|
||||
def _rules(text: str) -> tuple[MailClassification, float]:
|
||||
for kind, pattern, confidence in _RULES:
|
||||
if pattern.search(text):
|
||||
return kind, confidence
|
||||
return MailClassification.OTHER, 0.3
|
||||
|
||||
|
||||
def _has_negation(text: str) -> bool:
|
||||
return bool(_NEGATIONS.search(text))
|
||||
|
||||
|
||||
def _llm_classify(text: str, cfg: Config) -> tuple[MailClassification, float] | None:
|
||||
"""Ask the configured LLM. Returns None on any failure (rules take over)."""
|
||||
from cayceops.llm import get_provider
|
||||
|
||||
try:
|
||||
provider = get_provider(cfg)
|
||||
raw = provider.complete(
|
||||
_LLM_PROMPT.format(body=text[:4000]),
|
||||
system="You classify merchant emails. Reply with JSON only.",
|
||||
)
|
||||
data = json.loads(re.search(r"\{.*\}", raw, re.S).group(0))
|
||||
kind = MailClassification(data["classification"].lower())
|
||||
confidence = float(data["confidence"])
|
||||
except Exception: # noqa: BLE001 — LLM is best-effort by design
|
||||
return None
|
||||
if kind not in MailClassification or kind is MailClassification.NEEDS_REVIEW:
|
||||
return None
|
||||
return kind, min(max(confidence, 0.0), 1.0)
|
||||
|
||||
|
||||
def classify(text: str, cfg: Config) -> tuple[MailClassification, float]:
|
||||
"""Classify merchant text. Degrades to rules when no LLM is configured."""
|
||||
if cfg.llm_provider != "null":
|
||||
llm_result = _llm_classify(text, cfg)
|
||||
if llm_result is not None:
|
||||
kind, confidence = llm_result
|
||||
else: # LLM failed — fall through to rules
|
||||
kind, confidence = _rules(text)
|
||||
else:
|
||||
kind, confidence = _rules(text)
|
||||
|
||||
# APPROVAL VETO (audit round 2): any negation anywhere in the mail
|
||||
# downgrades an approval to needs_review. Approval requires a mail with
|
||||
# zero negations — "cannot currently accept", "never accept",
|
||||
# "hasn't been approved", "no longer accepting" all land here.
|
||||
if kind is MailClassification.APPROVAL and _has_negation(text):
|
||||
return MailClassification.NEEDS_REVIEW, min(confidence, 0.6)
|
||||
|
||||
if confidence < cfg.llm_review_threshold:
|
||||
return MailClassification.NEEDS_REVIEW, confidence
|
||||
return kind, confidence
|
||||
@@ -0,0 +1,681 @@
|
||||
"""maild — classify merchant replies, draft responses, gated SMTP.
|
||||
|
||||
Flow
|
||||
----
|
||||
1. ``poll``: fetch unread inbound mail (IMAP), parse, thread to the proposal
|
||||
via ``In-Reply-To`` of a previously-sent draft, classify (rules + optional
|
||||
LLM), store, and create a REPLY DRAFT (never sent automatically).
|
||||
2. ``approve_draft``: a human approves a specific draft (audited).
|
||||
3. ``send_approved``: SMTP sends *only* drafts in state ``approved``.
|
||||
|
||||
Classification is conservative: anything below the confidence threshold becomes
|
||||
``needs_review`` and no draft is generated.
|
||||
|
||||
Public signatures match the CLI: ``poll()``, ``approve_draft(id, actor)``,
|
||||
``send_approved()``. The ``_*`` variants take an explicit session/fetcher for
|
||||
tests and composition.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import email
|
||||
import email.policy
|
||||
import imaplib
|
||||
import smtplib
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
|
||||
from cayceops.components.maild.classify import classify as _classify_text
|
||||
from cayceops.config import Config
|
||||
from cayceops.db import session_scope
|
||||
from cayceops.models import (
|
||||
DaemonRun,
|
||||
DraftStatus,
|
||||
MailClassification,
|
||||
MailDirection,
|
||||
MailMessage,
|
||||
Proposal,
|
||||
_content_hash,
|
||||
audit,
|
||||
)
|
||||
|
||||
GATE_ERROR = 2 # missing/invalid configuration or signature
|
||||
|
||||
|
||||
def _signing(cfg: Config) -> tuple[str, str]:
|
||||
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode."""
|
||||
from cayceops.config import ConfigError
|
||||
|
||||
if not cfg.operator_pubkey or not cfg.deployment_id:
|
||||
raise ConfigError(
|
||||
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
|
||||
"CAYCE_OPS_DEPLOYMENT_ID are both required"
|
||||
)
|
||||
return cfg.deployment_id, cfg.operator_pubkey
|
||||
|
||||
|
||||
# --- IMAP ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def connect_imap(cfg: Config) -> imaplib.IMAP4_SSL:
|
||||
client = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port)
|
||||
client.login(cfg.imap_user, cfg.imap_password)
|
||||
client.select(cfg.imap_mailbox)
|
||||
return client
|
||||
|
||||
|
||||
def _fetch_unread(cfg: Config) -> list[tuple[bytes, bytes]]:
|
||||
"""Fetch raw unread messages as [(imap_num, raw_bytes)].
|
||||
|
||||
Messages are NOT marked \\Seen here — marking happens only after the
|
||||
processed rows are committed (audit finding: a crash mid-batch used to
|
||||
silently lose the rest of the batch).
|
||||
"""
|
||||
client = connect_imap(cfg)
|
||||
try:
|
||||
_, data = client.search(None, "UNSEEN")
|
||||
out: list[tuple[bytes, bytes]] = []
|
||||
for num in data[0].split():
|
||||
_, msg_data = client.fetch(num, "(RFC822)")
|
||||
out.append((num, msg_data[0][1]))
|
||||
return out
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
|
||||
def _mark_seen(cfg: Config, nums: list[bytes]) -> None:
|
||||
"""Mark processed messages \\Seen (called after commit)."""
|
||||
if not nums:
|
||||
return
|
||||
client = connect_imap(cfg)
|
||||
try:
|
||||
for num in nums:
|
||||
client.store(num, "+FLAGS", "\\Seen")
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
|
||||
# --- parsing ------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_raw(raw: bytes) -> dict:
|
||||
"""Parse raw RFC822 bytes into our flat dict shape."""
|
||||
msg = email.message_from_bytes(raw, policy=email.policy.default)
|
||||
body = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain" and not part.get_filename():
|
||||
body = part.get_content()
|
||||
break
|
||||
else:
|
||||
body = msg.get_content()
|
||||
reply_to = msg.get("In-Reply-To") or msg.get("References") or ""
|
||||
message_id = (msg.get("Message-ID") or "").strip()
|
||||
if not message_id:
|
||||
message_id = f"<generated-{datetime.now(UTC).timestamp()}@localhost>"
|
||||
# store the bare address (envelope-safe); display names stay out of the DB
|
||||
from_addr = email.utils.parseaddr(msg.get("From") or "")[1].strip()[:320]
|
||||
return {
|
||||
"message_id": message_id[:998],
|
||||
"in_reply_to": reply_to.split()[-1].strip() if reply_to.strip() else None,
|
||||
"from_addr": from_addr,
|
||||
"subject": (msg.get("Subject") or "").strip()[:998],
|
||||
"body": body[:20000],
|
||||
}
|
||||
|
||||
|
||||
# --- proposal threading -------------------------------------------------------
|
||||
|
||||
|
||||
def _match_proposal(parsed: dict, sess) -> Proposal | None:
|
||||
"""Thread an inbound mail to its proposal.
|
||||
|
||||
1. In-Reply-To matches a SENT OUTBOUND message (our replies carry their own
|
||||
Message-ID and an outbound row, created at send time).
|
||||
2. Fallback: it matches ANY stored message (e.g. a second reply threaded to
|
||||
our first inbound) that already carries a proposal_id.
|
||||
"""
|
||||
irt = parsed.get("in_reply_to")
|
||||
if not irt:
|
||||
return None
|
||||
sent = (
|
||||
sess.query(MailMessage)
|
||||
.filter(
|
||||
MailMessage.direction == MailDirection.OUTBOUND,
|
||||
MailMessage.message_id == irt,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if sent is not None and sent.proposal_id is not None:
|
||||
return sess.get(Proposal, sent.proposal_id)
|
||||
chain = (
|
||||
sess.query(MailMessage)
|
||||
.filter(MailMessage.message_id == irt, MailMessage.proposal_id.isnot(None))
|
||||
.one_or_none()
|
||||
)
|
||||
if chain is not None:
|
||||
return sess.get(Proposal, chain.proposal_id)
|
||||
return None
|
||||
|
||||
|
||||
# --- drafts -------------------------------------------------------------------
|
||||
|
||||
_DRAFT_TEMPLATES = {
|
||||
MailClassification.APPROVAL: (
|
||||
"Hello,\n\nThank you for the approval regarding \"{title}\". "
|
||||
"We will proceed on our side and follow up with the next steps.\n\nBest regards,\nCayce"
|
||||
),
|
||||
MailClassification.REJECTION: (
|
||||
"Hello,\n\nThank you for considering \"{title}\". "
|
||||
"We appreciate the reply and would welcome the chance to revisit in the future.\n\n"
|
||||
"Best regards,\nCayce"
|
||||
),
|
||||
MailClassification.QUESTION: (
|
||||
"Hello,\n\nRegarding \"{title}\": {question}\n\n"
|
||||
"Could you help us with the details so we can move forward?\n\nBest regards,\nCayce"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_draft(parsed: dict, kind: MailClassification, proposal: Proposal | None) -> str:
|
||||
title = proposal.title if proposal else (parsed.get("subject") or "your message")
|
||||
template = _DRAFT_TEMPLATES.get(kind)
|
||||
if template is None:
|
||||
return ""
|
||||
question = (parsed.get("body") or "")[:200].strip() or "we had a question"
|
||||
return template.format(title=title[:120], question=question)
|
||||
|
||||
|
||||
# --- inbound pass ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _poll(cfg: Config, sess, fetcher=None, marker=None) -> tuple[dict, list[bytes]]:
|
||||
"""One inbound pass: parse, classify, thread, draft.
|
||||
|
||||
Returns (stats, processed_imap_nums). The caller marks \\Seen only after
|
||||
its transaction commits.
|
||||
"""
|
||||
stats = {"processed": 0, "drafted": 0, "needs_review": 0, "skipped": 0}
|
||||
processed_nums: list[bytes] = []
|
||||
for num, raw in (fetcher or _fetch_unread)(cfg):
|
||||
parsed = parse_raw(raw)
|
||||
if (
|
||||
sess.query(MailMessage)
|
||||
.filter(MailMessage.message_id == parsed["message_id"])
|
||||
.one_or_none()
|
||||
):
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
proposal = _match_proposal(parsed, sess)
|
||||
kind, confidence = _classify_text(f"{parsed['subject']}\n{parsed['body']}", cfg)
|
||||
if proposal is None and kind == MailClassification.APPROVAL:
|
||||
# an approval we cannot thread is suspicious — human must look
|
||||
kind = MailClassification.NEEDS_REVIEW
|
||||
confidence = min(confidence, 0.4)
|
||||
|
||||
record = MailMessage(
|
||||
direction=MailDirection.INBOUND,
|
||||
message_id=parsed["message_id"],
|
||||
in_reply_to=parsed["in_reply_to"],
|
||||
from_addr=parsed["from_addr"] or None,
|
||||
subject=parsed["subject"] or None,
|
||||
body=parsed["body"],
|
||||
classification=kind,
|
||||
confidence=confidence,
|
||||
proposal_id=proposal.id if proposal else None,
|
||||
processed_at=datetime.now(UTC),
|
||||
)
|
||||
sess.add(record)
|
||||
sess.flush()
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="classify",
|
||||
proposal_id=proposal.id if proposal else None,
|
||||
detail={"classification": kind.value, "confidence": confidence},
|
||||
)
|
||||
stats["processed"] += 1
|
||||
processed_nums.append(num) # stored -> safe to mark seen after commit
|
||||
|
||||
if kind == MailClassification.NEEDS_REVIEW:
|
||||
stats["needs_review"] += 1
|
||||
continue
|
||||
|
||||
if proposal is not None:
|
||||
proposal.detail = {
|
||||
**(proposal.detail or {}),
|
||||
"merchant_status": kind.value,
|
||||
"merchant_reply_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
draft = _build_draft(parsed, kind, proposal)
|
||||
if draft:
|
||||
record.draft_reply = draft
|
||||
record.draft_status = DraftStatus.DRAFTED
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="draft_reply",
|
||||
proposal_id=proposal.id if proposal else None,
|
||||
detail={"mail_id": record.id},
|
||||
)
|
||||
stats["drafted"] += 1
|
||||
return stats, processed_nums
|
||||
|
||||
|
||||
# --- human gate: approve draft -------------------------------------------------
|
||||
|
||||
|
||||
def _mail_to_addr(record) -> str | None:
|
||||
"""The address a send would use for this draft."""
|
||||
if record.direction == MailDirection.INBOUND:
|
||||
return record.from_addr
|
||||
return record.to_addr
|
||||
|
||||
|
||||
def _mail_content_hash(record) -> str:
|
||||
from cayceops.signing import mail_content_hash
|
||||
|
||||
return mail_content_hash(
|
||||
_mail_to_addr(record), record.subject, record.draft_reply
|
||||
)
|
||||
|
||||
|
||||
def _approve_draft(
|
||||
mail_id: int, actor: str, sess, signature: str | None = None
|
||||
) -> int:
|
||||
"""Human approves a drafted reply (inbound-threaded or first-contact).
|
||||
|
||||
Signature is MANDATORY and bound to (to_addr, subject, body) + deployment
|
||||
(audit round 2, 1a/1b). The full signature is stored on the row.
|
||||
"""
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.signing import SignatureError, verify_signature
|
||||
|
||||
record = sess.get(MailMessage, mail_id)
|
||||
if record is None:
|
||||
return 1
|
||||
if record.draft_status != DraftStatus.DRAFTED or not record.draft_reply:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"reason": f"invalid draft state: {record.draft_status.value}"},
|
||||
)
|
||||
return 1
|
||||
|
||||
from cayceops.config import load
|
||||
|
||||
cfg = load()
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except ConfigError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"reason": str(exc)},
|
||||
)
|
||||
return GATE_ERROR
|
||||
|
||||
chash = _mail_content_hash(record)
|
||||
try:
|
||||
if not signature:
|
||||
raise SignatureError("signature required")
|
||||
if not verify_signature(
|
||||
"mail", deployment_id, mail_id, chash, signature, pubkey
|
||||
):
|
||||
raise SignatureError("signature invalid for this content/deployment")
|
||||
except SignatureError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"reason": str(exc), "content_hash": chash},
|
||||
)
|
||||
return GATE_ERROR
|
||||
|
||||
record.draft_status = DraftStatus.APPROVED
|
||||
record.approved_hash = _content_hash(record.draft_reply)
|
||||
record.approved_by = actor[:128]
|
||||
record.operator_signature = signature # FULL signature
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
detail={"mail_id": record.id, "hash": record.approved_hash, "signature": signature},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# --- SMTP: only approved drafts ------------------------------------------------
|
||||
|
||||
|
||||
def _smtp_send(
|
||||
cfg: Config,
|
||||
to_addr: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
message_id: str,
|
||||
in_reply_to: str | None = None,
|
||||
) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = cfg.mail_from
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = subject
|
||||
msg["Message-ID"] = message_id
|
||||
if in_reply_to:
|
||||
msg["In-Reply-To"] = in_reply_to # keep merchant threads intact
|
||||
msg.set_content(body)
|
||||
with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) as server:
|
||||
server.starttls()
|
||||
server.login(cfg.smtp_user, cfg.smtp_password)
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
def _approved_by(sess, mail_id: int) -> str:
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
for entry in sess.query(AuditEntry).all():
|
||||
if entry.action == "approve_draft" and entry.detail.get("mail_id") == mail_id:
|
||||
return entry.actor
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
|
||||
"""Send ONE approved draft. Returns: sent | hash_mismatch | missing | blocked.
|
||||
|
||||
Creates the OUTBOUND row (its own Message-ID + In-Reply-To pointing at the
|
||||
inbound message) so future replies thread correctly — the inbound row's
|
||||
message_id is left untouched (audit finding: it used to be overwritten).
|
||||
The caller owns the transaction: one message per commit, so an SMTP failure
|
||||
on message N never un-sends messages 1..N-1 (audit finding: duplicates).
|
||||
"""
|
||||
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.signing import verify_signature
|
||||
|
||||
record = sess.get(MailMessage, mail_id)
|
||||
if record is None:
|
||||
return "missing"
|
||||
if record.draft_status != DraftStatus.APPROVED:
|
||||
return "blocked"
|
||||
|
||||
# run-time signature gate (audit round 2, 1d): the row must carry a valid
|
||||
# operator signature for its CURRENT to_addr/subject/body + deployment.
|
||||
# Unsigned rows (raw SQL approval) or edited ones revert to DRAFTED.
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except ConfigError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="send_refused",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"mail_id": record.id, "reason": str(exc)},
|
||||
)
|
||||
return "unsigned"
|
||||
current_hash = _mail_content_hash(record)
|
||||
if not verify_signature(
|
||||
"mail", deployment_id, record.id, current_hash, record.operator_signature, pubkey
|
||||
):
|
||||
record.draft_status = DraftStatus.DRAFTED # back to pre-approval
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="send_refused",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={
|
||||
"mail_id": record.id,
|
||||
"reason": "no valid operator signature for current content/deployment",
|
||||
"content_hash": current_hash,
|
||||
"had_signature": bool(record.operator_signature),
|
||||
},
|
||||
)
|
||||
return "hash_mismatch"
|
||||
|
||||
if record.direction == MailDirection.INBOUND:
|
||||
# reply: goes back to the inbound sender, threads to the inbound mail,
|
||||
# and gets its own OUTBOUND row
|
||||
to_addr = record.from_addr or ""
|
||||
subject = f"Re: {record.subject or 'your message'}"
|
||||
in_reply_to = record.message_id
|
||||
outbound_msgid = (
|
||||
f"<cayce-out-{record.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>"
|
||||
)
|
||||
needs_row = True
|
||||
else:
|
||||
# first-contact draft: already an OUTBOUND row; send as-is
|
||||
to_addr = record.to_addr or ""
|
||||
subject = record.subject or ""
|
||||
in_reply_to = None
|
||||
outbound_msgid = record.message_id
|
||||
needs_row = False
|
||||
if not to_addr:
|
||||
record.draft_status = DraftStatus.SKIPPED
|
||||
return "blocked"
|
||||
|
||||
(sender or _smtp_send)(
|
||||
cfg,
|
||||
to_addr,
|
||||
subject,
|
||||
record.draft_reply or "",
|
||||
outbound_msgid,
|
||||
in_reply_to=in_reply_to,
|
||||
)
|
||||
approved_by = _approved_by(sess, record.id)
|
||||
if needs_row:
|
||||
sess.add(
|
||||
MailMessage(
|
||||
direction=MailDirection.OUTBOUND,
|
||||
message_id=outbound_msgid,
|
||||
in_reply_to=record.message_id,
|
||||
from_addr=cfg.mail_from,
|
||||
to_addr=to_addr,
|
||||
subject=subject,
|
||||
body=record.draft_reply,
|
||||
proposal_id=record.proposal_id,
|
||||
draft_status=DraftStatus.SENT,
|
||||
processed_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
record.draft_status = DraftStatus.SENT
|
||||
sess.flush()
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="send_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
detail={
|
||||
"mail_id": record.id,
|
||||
"message_id": outbound_msgid,
|
||||
"approved_by": approved_by,
|
||||
},
|
||||
)
|
||||
return "sent"
|
||||
|
||||
|
||||
def _send_approved(cfg: Config, sess, sender=None, factory=None) -> dict:
|
||||
"""Send every APPROVED draft, one transaction per message.
|
||||
|
||||
`factory` is a SQLAlchemy sessionmaker bound to the shared engine; each
|
||||
message is processed and committed in its own session (tests inject the
|
||||
engine's maker; the CLI passes appdb.session_factory()).
|
||||
"""
|
||||
stats = {"sent": 0, "blocked": 0, "hash_mismatch": 0, "missing": 0}
|
||||
ids = [
|
||||
row.id
|
||||
for row in sess.query(MailMessage.id)
|
||||
.filter(MailMessage.draft_status == DraftStatus.APPROVED)
|
||||
.all()
|
||||
]
|
||||
for mail_id in ids:
|
||||
# one transaction per message — an SMTP failure on message N must not
|
||||
# roll back (and later re-send) messages 1..N-1 (audit finding 3)
|
||||
try:
|
||||
if factory is not None:
|
||||
with factory() as one_sess: # sessionmaker() -> Session
|
||||
outcome = _send_one(cfg, one_sess, mail_id, sender)
|
||||
one_sess.commit()
|
||||
else:
|
||||
outcome = _send_one(cfg, sess, mail_id, sender)
|
||||
except Exception as exc: # noqa: BLE001 — isolate the failed message
|
||||
outcome = "error"
|
||||
# persist the failure in its OWN transaction (the failed message's
|
||||
# transaction is dead)
|
||||
try:
|
||||
error_sess = factory() if factory is not None else None
|
||||
if error_sess is not None:
|
||||
audit(
|
||||
error_sess,
|
||||
actor="daemon:maild",
|
||||
action="send_failed",
|
||||
result="error",
|
||||
detail={"mail_id": mail_id, "error": str(exc)[:200]},
|
||||
)
|
||||
error_sess.commit()
|
||||
error_sess.close()
|
||||
except Exception: # noqa: S110 — best-effort failure audit
|
||||
pass
|
||||
stats[outcome] = stats.get(outcome, 0) + 1
|
||||
return stats
|
||||
|
||||
|
||||
def _approved_by(sess, mail_id: int) -> str:
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
for entry in sess.query(AuditEntry).all():
|
||||
if entry.action == "approve_draft" and entry.detail.get("mail_id") == mail_id:
|
||||
return entry.actor
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _compose(proposal_id: int, to_addr: str, subject: str, body: str, sess) -> int:
|
||||
"""Create the FIRST-CONTACT outbound draft for a proposal (state: drafted).
|
||||
|
||||
This is the missing path the audit flagged: without it, threading only
|
||||
worked for replies to mails we never actually sent.
|
||||
"""
|
||||
proposal = sess.get(Proposal, proposal_id)
|
||||
if proposal is None or not to_addr:
|
||||
return -1
|
||||
outbound = MailMessage(
|
||||
direction=MailDirection.OUTBOUND,
|
||||
message_id=f"<cayce-out-{proposal.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>",
|
||||
from_addr=None, # filled from MAIL_FROM at send time
|
||||
to_addr=to_addr[:320],
|
||||
subject=subject[:998],
|
||||
body=body,
|
||||
draft_reply=body, # canonical approved-text field (same as reply drafts)
|
||||
proposal_id=proposal.id,
|
||||
draft_status=DraftStatus.DRAFTED,
|
||||
)
|
||||
sess.add(outbound)
|
||||
sess.flush()
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="compose_draft",
|
||||
proposal_id=proposal.id,
|
||||
detail={"mail_id": outbound.id},
|
||||
)
|
||||
return outbound.id
|
||||
|
||||
|
||||
# --- CLI wrappers (session scope lives here) ------------------------------------
|
||||
|
||||
|
||||
def poll(cfg: Config | None = None) -> int:
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
nums: list[bytes] = []
|
||||
with session_scope() as sess:
|
||||
run_row = DaemonRun(daemon="maild")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
stats, nums = _poll(cfg, sess)
|
||||
except Exception as exc: # noqa: BLE001 — daemon boundary
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"error": str(exc)[:300]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 1
|
||||
run_row.status = "ok"
|
||||
run_row.detail = stats
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
# committed: now it is safe to mark the batch seen
|
||||
if nums:
|
||||
_mark_seen(cfg, nums)
|
||||
return 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def approve_draft(mail_id: int, actor: str, signature: str | None = None) -> int:
|
||||
from cayceops.db import engine
|
||||
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
return _approve_draft(mail_id, actor, sess, signature)
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def send_approved(cfg: Config | None = None) -> int:
|
||||
"""CLI: same code path as the tests (_send_approved with factory).
|
||||
|
||||
Failures are audited (send_failed), recorded in daemon_runs, and any
|
||||
error yields a non-zero exit code (audit round 2, point 5).
|
||||
"""
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine, session_factory
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
factory = session_factory()
|
||||
with session_scope() as sess:
|
||||
run_row = DaemonRun(daemon="maild-send")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
_signing(cfg) # no degraded mode
|
||||
except Exception as exc: # noqa: BLE001 — ConfigError
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"refused": str(exc)[:200]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
print(f"send refused: {exc}")
|
||||
return 2
|
||||
stats = _send_approved(cfg, sess, factory=factory)
|
||||
run_row.status = "error" if stats.get("error") else "ok"
|
||||
run_row.detail = stats
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
print(stats)
|
||||
# any failure class is a non-zero exit (audit round 2, point 5)
|
||||
failures = stats.get("error") or stats.get("hash_mismatch") or stats.get("unsigned")
|
||||
return 1 if failures else 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def compose(proposal_id: int, to_addr: str, subject: str, body: str) -> int:
|
||||
from cayceops.db import engine
|
||||
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
mail_id = _compose(proposal_id, to_addr, subject, body, sess)
|
||||
return 0 if mail_id >= 0 else 1
|
||||
finally:
|
||||
engine().dispose()
|
||||
@@ -0,0 +1 @@
|
||||
"""monitor package."""
|
||||
@@ -0,0 +1,253 @@
|
||||
"""monitor — static status page builder.
|
||||
|
||||
Reads the shared database and renders a self-contained HTML file (no external
|
||||
assets, no JS, everything escaped). Contains NO sensitive data: daemon health,
|
||||
proposal queue counts, and recent audit activity (actor/action/result only —
|
||||
no email bodies, no detail payloads, no URLs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from cayceops.config import Config
|
||||
from cayceops.db import session_scope
|
||||
from cayceops.models import (
|
||||
AuditEntry,
|
||||
ContentItem,
|
||||
DaemonRun,
|
||||
MailMessage,
|
||||
Proposal,
|
||||
ProposalStatus,
|
||||
)
|
||||
|
||||
DAEMONS = ("scanner", "executor", "maild", "publisher", "monitor")
|
||||
|
||||
_STYLE = """<style>
|
||||
body{font-family:ui-monospace,Menlo,monospace;background:#0a0908;color:#f2ece0;margin:2rem}
|
||||
h1{color:#c9a84c;font-weight:600} h2{color:#c9a84c;margin-top:2rem;font-size:1.1rem}
|
||||
table{border-collapse:collapse;margin:.5rem 0}
|
||||
td,th{border:1px solid #333;padding:.35rem .8rem;text-align:left}
|
||||
.ok{color:#6fce6f}.error{color:#ef6b6b}.muted{color:#888;font-size:.85rem}
|
||||
.card{display:inline-block;background:#141210;border:1px solid #2a2a2a;
|
||||
border-radius:8px;padding:.8rem 1.4rem;margin:.3rem}
|
||||
</style>"""
|
||||
|
||||
|
||||
def _fmt_ts(dt) -> str:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%SZ") if dt else "—"
|
||||
|
||||
|
||||
def _daemon_rows(sess) -> str:
|
||||
rows = []
|
||||
for name in DAEMONS:
|
||||
last = (
|
||||
sess.query(DaemonRun)
|
||||
.filter(DaemonRun.daemon == name)
|
||||
.order_by(DaemonRun.started_at.desc())
|
||||
.first()
|
||||
)
|
||||
if last is None:
|
||||
rows.append(
|
||||
"<tr><td>" + name + "</td><td class=muted>never run</td><td>—</td></tr>"
|
||||
)
|
||||
else:
|
||||
cls = "ok" if last.status == "ok" else "error"
|
||||
when = _fmt_ts(last.finished_at or last.started_at)
|
||||
rows.append(
|
||||
"<tr><td>" + html.escape(name) + "</td>"
|
||||
+ '<td class="' + cls + '">' + html.escape(last.status) + "</td>"
|
||||
+ "<td>" + when + "</td></tr>"
|
||||
)
|
||||
head = "<tr><th>daemon</th><th>last status</th><th>last run</th></tr>"
|
||||
return "<table>" + head + "".join(rows) + "</table>"
|
||||
|
||||
|
||||
def _queue_cards(sess) -> str:
|
||||
counts = dict(
|
||||
sess.query(Proposal.status, func.count(Proposal.id)).group_by(Proposal.status).all()
|
||||
)
|
||||
cards = []
|
||||
for status in ProposalStatus:
|
||||
value = counts.get(status, 0)
|
||||
cards.append(
|
||||
"<div class=card>" + html.escape(status.value)
|
||||
+ ": <strong>" + str(value) + "</strong></div>"
|
||||
)
|
||||
return "".join(cards)
|
||||
|
||||
|
||||
def _recent_audit(sess, limit: int = 20) -> str:
|
||||
rows = []
|
||||
query = sess.query(AuditEntry).order_by(AuditEntry.ts.desc()).limit(limit)
|
||||
for entry in query.all():
|
||||
cls = "ok" if entry.result == "ok" else "error"
|
||||
rows.append(
|
||||
"<tr><td>" + _fmt_ts(entry.ts) + "</td>"
|
||||
+ "<td>" + html.escape(entry.actor) + "</td>"
|
||||
+ "<td>" + html.escape(entry.action) + "</td>"
|
||||
+ '<td class="' + cls + '">' + html.escape(entry.result) + "</td></tr>"
|
||||
)
|
||||
body = "".join(rows) or "<tr><td colspan=4 class=muted>no activity yet</td></tr>"
|
||||
return (
|
||||
"<table><tr><th>time (utc)</th><th>actor</th><th>action</th><th>result</th></tr>"
|
||||
+ body
|
||||
+ "</table>"
|
||||
)
|
||||
|
||||
|
||||
def _build(cfg: Config, sess) -> str:
|
||||
now = datetime.now(UTC)
|
||||
mail_unprocessed = (
|
||||
sess.query(func.count(MailMessage.id))
|
||||
.filter(
|
||||
MailMessage.direction == "in",
|
||||
MailMessage.draft_status.in_(["drafted", "none"]),
|
||||
MailMessage.classification.isnot(None),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
content_pending = (
|
||||
sess.query(func.count(ContentItem.id))
|
||||
.filter(ContentItem.status.in_(["draft", "approved"]))
|
||||
.scalar()
|
||||
)
|
||||
generated = _fmt_ts(now)
|
||||
|
||||
page = f"""<!doctype html>
|
||||
<html lang=en><head><meta charset=utf-8><title>cayce-ops status</title>{_STYLE}</head>
|
||||
<body>
|
||||
<h1>cayce-ops · status</h1>
|
||||
<p class=muted>generated {generated} · static page, no sensitive data by design</p>
|
||||
|
||||
<h2>daemon health</h2>
|
||||
{_daemon_rows(sess)}
|
||||
|
||||
<h2>proposal queue</h2>
|
||||
{_queue_cards(sess)}
|
||||
|
||||
<h2>gates</h2>
|
||||
<div class=card>mail drafts awaiting approval: <strong>{mail_unprocessed}</strong></div>
|
||||
<div class=card>content items draft/approved: <strong>{content_pending}</strong></div>
|
||||
|
||||
<h2>recent audit activity</h2>
|
||||
{_recent_audit(sess)}
|
||||
|
||||
<p class=muted>audit entries: {sess.scalar(select(func.count(AuditEntry.id)))} total</p>
|
||||
</body></html>"""
|
||||
|
||||
import os
|
||||
|
||||
os.makedirs(os.path.dirname(cfg.monitor_output) or ".", exist_ok=True)
|
||||
with open(cfg.monitor_output, "w", encoding="utf-8") as fh:
|
||||
fh.write(page)
|
||||
return cfg.monitor_output
|
||||
|
||||
|
||||
def _anchor(cfg: Config, sess) -> int:
|
||||
"""Anchor core (injectable session for tests). Returns 0 ok / 0 skip."""
|
||||
from cayceops.components.publisher import nostr
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.models import AuditEntry, audit
|
||||
|
||||
if not cfg.nostr_private_key or not cfg.nostr_relays:
|
||||
raise ConfigError(
|
||||
"anchor requires CAYCE_OPS_NOSTR_PRIVATE_KEY and CAYCE_OPS_NOSTR_RELAYS"
|
||||
)
|
||||
if not cfg.deployment_id:
|
||||
raise ConfigError("anchor requires CAYCE_OPS_DEPLOYMENT_ID")
|
||||
|
||||
last = (
|
||||
sess.query(AuditEntry)
|
||||
.filter(AuditEntry.entry_hash.isnot(None))
|
||||
.order_by(AuditEntry.id.desc())
|
||||
.first()
|
||||
)
|
||||
if last is None:
|
||||
print("nothing to anchor: chain empty")
|
||||
return 0
|
||||
today = datetime.now(UTC).date()
|
||||
last_anchor = (
|
||||
sess.query(AuditEntry)
|
||||
.filter(AuditEntry.action == "anchor")
|
||||
.order_by(AuditEntry.id.desc())
|
||||
.first()
|
||||
)
|
||||
if last_anchor is not None and last_anchor.ts.date() == today:
|
||||
print("already anchored today")
|
||||
return 0
|
||||
|
||||
secret = nostr.normalize_key(cfg.nostr_private_key)
|
||||
event = nostr.build_event(
|
||||
secret,
|
||||
f"cayce-ops audit anchor deployment={cfg.deployment_id} "
|
||||
f"last_hash={last.entry_hash} entries={last.id}",
|
||||
tags=[["t", "cayce-ops"], ["t", "audit-anchor"]],
|
||||
)
|
||||
if not nostr.verify_event(event):
|
||||
raise RuntimeError("anchor event failed self-verification")
|
||||
results = nostr.publish(cfg.nostr_relays, event)
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:monitor",
|
||||
action="anchor",
|
||||
detail={
|
||||
"last_hash": last.entry_hash,
|
||||
"last_entry": last.id,
|
||||
"event_id": event["id"],
|
||||
"relays": results,
|
||||
"deployment": cfg.deployment_id,
|
||||
},
|
||||
)
|
||||
print(f"anchored: {event['id']}")
|
||||
return 0
|
||||
|
||||
|
||||
def anchor(cfg: Config | None = None) -> int:
|
||||
"""CLI: anchor once per day (external anchoring, audit round 2 point 4)."""
|
||||
from cayceops.config import ConfigError, load
|
||||
from cayceops.db import engine
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
try:
|
||||
return _anchor(cfg, sess)
|
||||
except ConfigError as exc:
|
||||
print(f"anchor refused: {exc}")
|
||||
return 2
|
||||
except Exception as exc: # noqa: BLE001 — CLI boundary
|
||||
print(f"anchor failed: {str(exc)[:200]}")
|
||||
return 1
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def build(cfg: Config | None = None) -> int:
|
||||
"""CLI entry: render the status page inside a DaemonRun bookkeeping wrap."""
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
run_row = DaemonRun(daemon="monitor")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
out = _build(cfg, sess)
|
||||
except Exception as exc: # noqa: BLE001 — daemon boundary
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"error": str(exc)[:300]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 1
|
||||
run_row.status = "ok"
|
||||
run_row.detail = {"output": out}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
print(out)
|
||||
return 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
@@ -0,0 +1 @@
|
||||
"""publisher package."""
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Optional Facebook/Instagram adapters (Graph API).
|
||||
|
||||
DISABLED BY DEFAULT. Each adapter refuses to run unless its enable flag is
|
||||
true (``CAYCE_OPS_ENABLE_FACEBOOK`` / ``CAYCE_OPS_ENABLE_INSTAGRAM``), so the
|
||||
default deployment never touches Meta endpoints. Tokens are env-only and are
|
||||
never logged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
GRAPH = "https://graph.facebook.com/v21.0"
|
||||
|
||||
|
||||
def _post(url: str, payload: dict) -> dict:
|
||||
data = urllib.parse.urlencode(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, method="POST") # noqa: S310
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def post_facebook(cfg, caption: str, media_path: str | None = None) -> dict:
|
||||
"""Post to the page feed (text) or /photos (with local media). Gated."""
|
||||
if not cfg.enable_facebook:
|
||||
raise PermissionError("facebook adapter disabled (CAYCE_OPS_ENABLE_FACEBOOK=false)")
|
||||
if not cfg.facebook_page_token:
|
||||
raise ValueError("CAYCE_OPS_FACEBOOK_PAGE_TOKEN missing")
|
||||
if media_path:
|
||||
# photo posts require a reachable URL or multipart upload; we use the
|
||||
# simple URL form — media must be hosted (out of scope for local files)
|
||||
result = _post(
|
||||
f"{GRAPH}/me/photos",
|
||||
{"url": media_path, "caption": caption, "access_token": cfg.facebook_page_token},
|
||||
)
|
||||
else:
|
||||
result = _post(
|
||||
f"{GRAPH}/me/feed",
|
||||
{"message": caption, "access_token": cfg.facebook_page_token},
|
||||
)
|
||||
return {"post_id": result.get("post_id") or result.get("id", "")}
|
||||
|
||||
|
||||
def post_instagram(cfg, caption: str, image_url: str) -> dict:
|
||||
"""Two-step IG publish (container -> publish). Gated."""
|
||||
if not cfg.enable_instagram:
|
||||
raise PermissionError("instagram adapter disabled (CAYCE_OPS_ENABLE_INSTAGRAM=false)")
|
||||
if not cfg.instagram_user_id:
|
||||
raise ValueError("CAYCE_OPS_INSTAGRAM_USER_ID missing")
|
||||
token = cfg.facebook_page_token # IG Graph publishing uses the page token
|
||||
container = _post(
|
||||
f"{GRAPH}/{cfg.instagram_user_id}/media",
|
||||
{"image_url": image_url, "caption": caption, "access_token": token},
|
||||
)
|
||||
published = _post(
|
||||
f"{GRAPH}/{cfg.instagram_user_id}/media_publish",
|
||||
{"creation_id": container["id"], "access_token": token},
|
||||
)
|
||||
return {"post_id": published.get("id", "")}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Brand image templates (Pillow). 1080x1350 portrait, two brand styles.
|
||||
|
||||
Deterministic, template-driven graphics — deliberately not AI-generated media
|
||||
(DECISIONS: nothing here trips AI-media disclosure requirements; it is typeset
|
||||
design). Fonts: Pillow's bundled default (scalable), so no system font deps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
WIDTH, HEIGHT = 1080, 1350
|
||||
|
||||
_BRANDS = {
|
||||
# bitcoinhotels — warm gold on near-black
|
||||
"bh": {
|
||||
"bg": (20, 18, 16),
|
||||
"accent": (201, 168, 76),
|
||||
"text": (242, 236, 224),
|
||||
"muted": (150, 140, 120),
|
||||
},
|
||||
# hardcap — bitcoin orange on cool black
|
||||
"hc": {
|
||||
"bg": (13, 13, 16),
|
||||
"accent": (247, 147, 26),
|
||||
"text": (250, 250, 250),
|
||||
"muted": (120, 120, 130),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _font(size: int, bold: bool = False):
|
||||
# Pillow >=10: load_default(size=...) returns a scalable bundled font
|
||||
return ImageFont.load_default(size=size)
|
||||
|
||||
|
||||
def _vgrad(top: tuple, bottom: tuple) -> Image.Image:
|
||||
"""Simple two-color vertical gradient."""
|
||||
img = Image.new("RGB", (WIDTH, HEIGHT))
|
||||
for y in range(HEIGHT):
|
||||
t = y / (HEIGHT - 1)
|
||||
row = tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3))
|
||||
ImageDraw.Draw(img).line([(0, y), (WIDTH, y)], fill=row)
|
||||
return img
|
||||
|
||||
|
||||
def render_brand_image(
|
||||
*,
|
||||
brand: str = "bh",
|
||||
kicker: str | None,
|
||||
lines: list[str],
|
||||
tagline: list[str] | None = None,
|
||||
url: str | None = None,
|
||||
out_path: str,
|
||||
) -> str:
|
||||
"""Render one branded poster. Returns out_path. Raises on unknown brand."""
|
||||
if brand not in _BRANDS:
|
||||
raise ValueError(f"unknown brand {brand!r} (have: {sorted(_BRANDS)})")
|
||||
palette = _BRANDS[brand]
|
||||
img = _vgrad(palette["bg"], tuple(max(0, c - 8) for c in palette["bg"]))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
x, y = 90, 240
|
||||
if kicker:
|
||||
draw.text((x, y), kicker, font=_font(40), fill=palette["muted"])
|
||||
y += 110
|
||||
for line in lines:
|
||||
draw.text((x, y), line, font=_font(96, bold=True), fill=palette["text"])
|
||||
y += 118
|
||||
y += 24
|
||||
for t in tagline or []:
|
||||
draw.text((x, y), t, font=_font(38), fill=palette["muted"])
|
||||
y += 58
|
||||
draw.rectangle([x, y + 30, x + 470, y + 33], fill=palette["accent"])
|
||||
if url:
|
||||
draw.text((x, y + 110), url, font=_font(44), fill=palette["accent"])
|
||||
|
||||
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
||||
img.save(out_path, "JPEG", quality=92)
|
||||
return out_path
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Minimal Nostr client: NIP-01 kind-1 notes (build, sign, verify, publish).
|
||||
|
||||
Implemented in-repo (DECISIONS D4) so every byte of the signing path is
|
||||
auditable: event id = sha256 of the canonical NIP-01 serialization; signature =
|
||||
BIP-340 Schnorr over the id. Keys are 32-byte hex. Relays are plain env config.
|
||||
|
||||
Publishing is fire-and-wait: open a websocket per relay, send the EVENT, wait
|
||||
for the OK frame (or timeout), and return the per-relay results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from coincurve import PrivateKey, PublicKey
|
||||
|
||||
KIND_NOTE = 1
|
||||
|
||||
|
||||
def normalize_key(private_key_hex: str) -> bytes:
|
||||
"""Accept 64-char hex (with or without 0x). Anything else is a config error."""
|
||||
key = private_key_hex.strip().lower().removeprefix("0x")
|
||||
if len(key) != 64 or not all(c in "0123456789abcdef" for c in key):
|
||||
raise ValueError("CAYCE_OPS_NOSTR_PRIVATE_KEY must be 64-char hex (32 bytes)")
|
||||
return bytes.fromhex(key)
|
||||
|
||||
|
||||
def pubkey_from_secret(secret: bytes) -> str:
|
||||
return PublicKey.from_secret(secret).format().hex()[2:]
|
||||
|
||||
|
||||
def build_event(
|
||||
secret: bytes,
|
||||
content: str,
|
||||
tags: list[list[str]] | None = None,
|
||||
created_at: int | None = None,
|
||||
kind: int = KIND_NOTE,
|
||||
) -> dict[str, Any]:
|
||||
"""Build and sign a NIP-01 event. Deterministic given the same inputs."""
|
||||
created_at = int(created_at if created_at is not None else __import__("time").time())
|
||||
pubkey = pubkey_from_secret(secret)
|
||||
serialized = json.dumps(
|
||||
[0, pubkey, created_at, kind, tags or [], content],
|
||||
separators=(",", ":"), # canonical NIP-01 form
|
||||
ensure_ascii=False,
|
||||
)
|
||||
event_id = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
# zero aux randomness -> fully deterministic signatures (BIP-340 permits it;
|
||||
# verification is unaffected either way)
|
||||
signature = (
|
||||
PrivateKey(secret)
|
||||
.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00" * 32)
|
||||
.hex()
|
||||
)
|
||||
return {
|
||||
"pubkey": pubkey,
|
||||
"created_at": created_at,
|
||||
"kind": kind,
|
||||
"tags": tags or [],
|
||||
"content": content,
|
||||
"id": event_id,
|
||||
"sig": signature,
|
||||
}
|
||||
|
||||
|
||||
def _schnorr_verify(sig32: bytes, msg32: bytes, xonly_pub32: bytes) -> bool:
|
||||
"""BIP-340 reference verification (pure Python, public domain spec code).
|
||||
|
||||
Signing runs in coincurve (libsecp256k1); verification is pure Python so
|
||||
the auditor can read every step without CFFI indirection.
|
||||
"""
|
||||
P = 2**256 - 2**32 - 977
|
||||
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||
|
||||
def tag_hash(tag: str, msg: bytes) -> bytes:
|
||||
t = hashlib.sha256(tag.encode()).digest()
|
||||
return hashlib.sha256(t + t + msg).digest()
|
||||
|
||||
def lift_x(b: int):
|
||||
if b >= P:
|
||||
return None
|
||||
y_sq = (pow(b, 3, P) + 7) % P
|
||||
y = pow(y_sq, (P + 1) // 4, P)
|
||||
if pow(y, 2, P) != y_sq:
|
||||
return None
|
||||
return (b, y if y % 2 == 0 else P - y)
|
||||
|
||||
def point_add(p1, p2):
|
||||
if p1 is None:
|
||||
return p2
|
||||
if p2 is None:
|
||||
return p1
|
||||
if p1[0] == p2[0] and p1[1] != p2[1]:
|
||||
return None
|
||||
if p1 == p2:
|
||||
lam = (3 * p1[0] * p1[0] * pow(2 * p1[1], P - 2, P)) % P
|
||||
else:
|
||||
lam = ((p2[1] - p1[1]) * pow(p2[0] - p1[0], P - 2, P)) % P
|
||||
x3 = (lam * lam - p1[0] - p2[0]) % P
|
||||
return (x3, (lam * (p1[0] - x3) - p1[1]) % P)
|
||||
|
||||
def point_mul(p, scalar):
|
||||
acc = None
|
||||
for i in range(256):
|
||||
if (scalar >> i) & 1:
|
||||
acc = point_add(acc, p)
|
||||
p = point_add(p, p)
|
||||
return acc
|
||||
|
||||
if len(sig32) != 64 or len(msg32) != 32 or len(xonly_pub32) != 32:
|
||||
return False
|
||||
r = int.from_bytes(sig32[:32], "big")
|
||||
s = int.from_bytes(sig32[32:], "big")
|
||||
if not (0 <= r < P and 0 <= s < N):
|
||||
return False
|
||||
pub_point = lift_x(int.from_bytes(xonly_pub32, "big"))
|
||||
if pub_point is None:
|
||||
return False
|
||||
e = int.from_bytes(tag_hash("BIP0340/challenge", sig32[:32] + xonly_pub32 + msg32), "big") % N
|
||||
generator = (
|
||||
0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, # Gx
|
||||
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, # Gy
|
||||
)
|
||||
r_point = point_add(
|
||||
point_mul(generator, s),
|
||||
point_mul(pub_point, (N - e) % N),
|
||||
)
|
||||
if r_point is None or r_point[1] % 2 != 0:
|
||||
return False
|
||||
return r_point[0] == r
|
||||
|
||||
|
||||
def verify_event(event: dict[str, Any]) -> bool:
|
||||
"""Recompute the id and verify the Schnorr signature against the pubkey."""
|
||||
serialized = json.dumps(
|
||||
[0, event["pubkey"], event["created_at"], event["kind"], event["tags"], event["content"]],
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if hashlib.sha256(serialized.encode("utf-8")).hexdigest() != event["id"]:
|
||||
return False
|
||||
# 33-byte compressed point (02-prefixed x-only)
|
||||
compressed = PublicKey(bytes.fromhex("02" + event["pubkey"])).format()
|
||||
return _schnorr_verify(
|
||||
bytes.fromhex(event["sig"]), bytes.fromhex(event["id"]), compressed[1:]
|
||||
)
|
||||
|
||||
|
||||
def verify_schnorr(sig_hex: str, message: bytes, pubkey_hex: str) -> bool:
|
||||
"""Verify a BIP-340 signature over an arbitrary 32-byte message."""
|
||||
try:
|
||||
return _schnorr_verify(
|
||||
bytes.fromhex(sig_hex), message, bytes.fromhex(pubkey_hex)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def relay_publish(relay_url: str, event: dict[str, Any], timeout: float = 10.0) -> str:
|
||||
"""Publish one event to one relay; return the relay's OK message."""
|
||||
from websockets.sync.client import connect
|
||||
|
||||
with connect(relay_url, close_timeout=timeout) as ws:
|
||||
ws.send(json.dumps(["EVENT", event]))
|
||||
reply = json.loads(ws.recv(timeout=timeout))
|
||||
if not (isinstance(reply, list) and len(reply) >= 3 and reply[0] == "OK" and reply[2] is True):
|
||||
raise RuntimeError(f"relay {relay_url} rejected event: {str(reply)[:200]}")
|
||||
return str(reply[1])
|
||||
|
||||
|
||||
def publish(
|
||||
relays: list[str], event: dict[str, Any], timeout: float = 10.0
|
||||
) -> list[dict[str, str]]:
|
||||
"""Publish to every relay; report per-relay outcome (never raises unless all fail)."""
|
||||
results = []
|
||||
for relay in relays:
|
||||
try:
|
||||
relay_publish(relay, event, timeout)
|
||||
results.append({"relay": relay, "ok": "true"})
|
||||
except Exception as exc: # noqa: BLE001 — per-relay isolation
|
||||
results.append({"relay": relay, "ok": "false", "error": str(exc)[:200]})
|
||||
if relays and all(r["ok"] == "false" for r in results):
|
||||
raise RuntimeError(f"all relays failed: {results}")
|
||||
return results
|
||||
@@ -0,0 +1,423 @@
|
||||
"""publisher — themed content calendar with a hard human gate on publishing.
|
||||
|
||||
State machine: draft -> approved -> published. Only ``approved`` items ever
|
||||
publish. AUTONOMOUS_MODE has NO effect on this component: content always
|
||||
requires explicit human approval (documented in AUDIT.md).
|
||||
|
||||
Channels: nostr (NIP-01, primary) — facebook/instagram adapters exist but are
|
||||
disabled unless explicitly enabled via env (DECISIONS: default deployment never
|
||||
touches Meta).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from cayceops.components.publisher import images, nostr
|
||||
from cayceops.config import Config
|
||||
from cayceops.db import session_scope
|
||||
from cayceops.models import (
|
||||
ContentItem,
|
||||
ContentStatus,
|
||||
DaemonRun,
|
||||
_content_hash,
|
||||
audit,
|
||||
)
|
||||
|
||||
# rotation: one theme per slot; channel rotation nostr -> facebook -> instagram
|
||||
THEMES = [
|
||||
("lightning-abroad", "bitcoinhotels.org/guides/lightning-abroad"),
|
||||
("seed-strategy", "hardcap.news/learn/travel-security-kit"),
|
||||
("atm-fees", "bitcoinhotels.org/guides/best-bitcoin-atms-by-city"),
|
||||
("elsalvador-budget", "bitcoinhotels.org/guides/living-on-sats-in-el-salvador"),
|
||||
]
|
||||
|
||||
_CAPTION_TEMPLATE = "{hook}\n\n{body}\n\n{link}"
|
||||
|
||||
|
||||
def _caption(theme: str, link: str) -> str:
|
||||
hooks = {
|
||||
"lightning-abroad": "Paying abroad with Lightning beats your bank.",
|
||||
"seed-strategy": "The seed strategy you pick decides how the border story goes.",
|
||||
"atm-fees": "The first ATM you find charges 10-15% over spot.",
|
||||
"elsalvador-budget": "A real weekly budget on a Bitcoin standard.",
|
||||
}
|
||||
bodies = {
|
||||
"lightning-abroad": (
|
||||
"No foreign transaction fees, no dynamic currency conversion, "
|
||||
"no declines. Field-tested guide."
|
||||
),
|
||||
"seed-strategy": (
|
||||
"Steel plate, memorized + decoy, or split custody — "
|
||||
"the full breakdown, brutally honest."
|
||||
),
|
||||
"atm-fees": (
|
||||
"The prepared traveler pays 4-6%. The difference is knowing "
|
||||
"the operator and the no-KYC cap."
|
||||
),
|
||||
"elsalvador-budget": (
|
||||
"Surf hostels, comidas, coworking — the corners where you still "
|
||||
"need dollars, and the 90-day protocol."
|
||||
),
|
||||
}
|
||||
return _CAPTION_TEMPLATE.format(
|
||||
hook=hooks.get(theme, theme.replace("-", " ").title()),
|
||||
body=bodies.get(theme, "Field-tested notes."),
|
||||
link=link,
|
||||
)
|
||||
|
||||
|
||||
# --- calendar planning ----------------------------------------------------------
|
||||
|
||||
|
||||
def _plan(cfg: Config, sess, days: int = 4) -> dict:
|
||||
"""Fill the calendar: one draft per theme/channel pair not already queued.
|
||||
|
||||
Idempotent by (theme, channel): existing draft/approved items block a
|
||||
duplicate. Drafts are scheduled one per day, starting tomorrow.
|
||||
"""
|
||||
created = 0
|
||||
for idx, (theme, link) in enumerate(THEMES[:days]):
|
||||
channel = ["nostr", "facebook", "instagram", "nostr"][idx % 4]
|
||||
exists = (
|
||||
sess.query(ContentItem)
|
||||
.filter(
|
||||
ContentItem.theme == theme,
|
||||
ContentItem.channel == channel,
|
||||
ContentItem.status != ContentStatus.PUBLISHED,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if exists:
|
||||
continue
|
||||
item = ContentItem(
|
||||
theme=theme,
|
||||
channel=channel,
|
||||
caption=_caption(theme, link),
|
||||
link=link,
|
||||
status=ContentStatus.DRAFT,
|
||||
scheduled_for=datetime.now(UTC).replace(hour=13, minute=0, second=0, microsecond=0)
|
||||
+ timedelta(days=idx + 1),
|
||||
)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="plan_draft",
|
||||
detail={"item": item.id, "theme": theme, "channel": channel},
|
||||
)
|
||||
created += 1
|
||||
return {"created": created}
|
||||
|
||||
|
||||
# --- human gate -------------------------------------------------------------------
|
||||
|
||||
|
||||
GATE_ERROR = 2 # missing/invalid configuration or signature
|
||||
|
||||
|
||||
def _signing(cfg: Config) -> tuple[str, str]:
|
||||
from cayceops.config import ConfigError
|
||||
|
||||
if not cfg.operator_pubkey or not cfg.deployment_id:
|
||||
raise ConfigError(
|
||||
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
|
||||
"CAYCE_OPS_DEPLOYMENT_ID are both required"
|
||||
)
|
||||
return cfg.deployment_id, cfg.operator_pubkey
|
||||
|
||||
|
||||
def _content_binding(item: ContentItem) -> str:
|
||||
from cayceops.signing import content_content_hash
|
||||
|
||||
return content_content_hash(item.channel, item.caption, item.link)
|
||||
|
||||
|
||||
def _approve(
|
||||
item_id: int, actor: str, sess, signature: str | None = None
|
||||
) -> int:
|
||||
"""Signature MANDATORY, bound to (channel, caption, link) + deployment."""
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.signing import SignatureError, verify_signature
|
||||
|
||||
item = sess.get(ContentItem, item_id)
|
||||
if item is None or item.status != ContentStatus.DRAFT:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_content",
|
||||
result="error",
|
||||
detail={"reason": f"item {item_id} not in draft state"},
|
||||
)
|
||||
return 1
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg_from(sess))
|
||||
except ConfigError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_content",
|
||||
result="error",
|
||||
detail={"reason": str(exc)},
|
||||
)
|
||||
return GATE_ERROR
|
||||
chash = _content_binding(item)
|
||||
try:
|
||||
if not signature:
|
||||
raise SignatureError("signature required")
|
||||
if not verify_signature(
|
||||
"content", deployment_id, item_id, chash, signature, pubkey
|
||||
):
|
||||
raise SignatureError("signature invalid for this content/deployment")
|
||||
except SignatureError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_content",
|
||||
result="error",
|
||||
detail={"reason": str(exc), "content_hash": chash},
|
||||
)
|
||||
return GATE_ERROR
|
||||
item.status = ContentStatus.APPROVED
|
||||
item.approved_hash = _content_hash(item.caption)
|
||||
item.operator_signature = signature # FULL signature
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_content",
|
||||
detail={
|
||||
"item": item.id,
|
||||
"channel": item.channel,
|
||||
"hash": item.approved_hash,
|
||||
"signature": signature,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cfg_from(sess):
|
||||
"""Config from env (publisher commands are single-invocation CLIs)."""
|
||||
from cayceops.config import load
|
||||
|
||||
return load()
|
||||
|
||||
|
||||
# --- image rendering ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_image(item: ContentItem, cfg: Config) -> str | None:
|
||||
"""Render the brand image for a nostr item (per its theme)."""
|
||||
brand = "hc" if "hardcap" in (item.link or "") else "bh"
|
||||
title = item.theme.replace("-", " ").upper().split()
|
||||
lines = [" ".join(title[:2])] if title else [item.theme]
|
||||
out = images.render_brand_image(
|
||||
brand=brand,
|
||||
kicker="C A Y C E",
|
||||
lines=lines,
|
||||
tagline=[(item.link or "")[:70]],
|
||||
url=(item.link or "")[:60],
|
||||
out_path=f"{cfg.media_dir}/item-{item.id}.jpg",
|
||||
)
|
||||
item.media_path = out
|
||||
return out
|
||||
|
||||
|
||||
# --- publishing ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _publish_nostr(item: ContentItem, cfg: Config, sess, sender=None) -> str:
|
||||
if not cfg.nostr_private_key or not cfg.nostr_relays:
|
||||
raise RuntimeError(
|
||||
"nostr channel requires CAYCE_OPS_NOSTR_PRIVATE_KEY and CAYCE_OPS_NOSTR_RELAYS"
|
||||
)
|
||||
secret = nostr.normalize_key(cfg.nostr_private_key)
|
||||
event = nostr.build_event(secret, item.caption, tags=[["t", item.theme]])
|
||||
if not nostr.verify_event(event): # pragma: no cover — build/verify symmetry
|
||||
raise RuntimeError("event failed self-verification before publish")
|
||||
results = (sender or nostr.publish)(cfg.nostr_relays, event)
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="nostr_relay_results",
|
||||
detail={"event": event["id"], "relays": results},
|
||||
)
|
||||
return event["id"]
|
||||
|
||||
|
||||
def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
|
||||
"""Publish approved items that are due. Non-nostr channels are skipped
|
||||
unless their adapters are explicitly enabled (default: skipped, audited)."""
|
||||
stats = {"published": 0, "skipped": 0, "failed": 0, "tampered": 0}
|
||||
due = (
|
||||
sess.query(ContentItem)
|
||||
.filter(ContentItem.status == ContentStatus.APPROVED)
|
||||
.all()
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
for item in due:
|
||||
if item.scheduled_for and item.scheduled_for > now:
|
||||
continue # not due yet
|
||||
|
||||
# run-time signature gate (audit round 2, 1d): valid operator
|
||||
# signature required for CURRENT channel/caption/link + deployment.
|
||||
# Unsigned rows (raw SQL approval) or edited ones revert to DRAFT.
|
||||
from cayceops.signing import verify_signature
|
||||
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except Exception as exc: # noqa: BLE001 — ConfigError
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_refused",
|
||||
result="error",
|
||||
detail={"item": item.id, "reason": str(exc)},
|
||||
)
|
||||
stats["failed"] += 1
|
||||
continue
|
||||
if not verify_signature(
|
||||
"content",
|
||||
deployment_id,
|
||||
item.id,
|
||||
_content_binding(item),
|
||||
item.operator_signature,
|
||||
pubkey,
|
||||
):
|
||||
item.status = ContentStatus.DRAFT # back to pre-approval
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_refused",
|
||||
result="error",
|
||||
detail={
|
||||
"item": item.id,
|
||||
"reason": "no valid operator signature for current content/deployment",
|
||||
},
|
||||
)
|
||||
stats["tampered"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
if item.channel == "nostr":
|
||||
item.post_ref = _publish_nostr(item, cfg, sess, sender=nostr_sender)
|
||||
else:
|
||||
# disabled channel is TERMINAL (audit finding: this used to
|
||||
# write a skip audit row every 5 minutes, forever)
|
||||
item.status = ContentStatus.SKIPPED
|
||||
item.error = f"{item.channel} adapter disabled by default"
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_skipped_terminal",
|
||||
detail={"item": item.id, "channel": item.channel},
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001 — publish boundary: audit and continue
|
||||
item.attempts += 1
|
||||
item.error = str(exc)[:300]
|
||||
if item.attempts >= cfg.max_attempts:
|
||||
# attempt budget exhausted -> terminal (no infinite retries)
|
||||
item.status = ContentStatus.SKIPPED
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_abandoned",
|
||||
result="error",
|
||||
detail={"item": item.id, "attempts": item.attempts},
|
||||
)
|
||||
else:
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_failed",
|
||||
result="error",
|
||||
detail={"item": item.id, "error": item.error, "attempt": item.attempts},
|
||||
)
|
||||
stats["failed"] += 1
|
||||
continue
|
||||
item.status = ContentStatus.PUBLISHED
|
||||
item.published_at = datetime.now(UTC)
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_content",
|
||||
detail={"item": item.id, "channel": item.channel, "ref": item.post_ref},
|
||||
)
|
||||
stats["published"] += 1
|
||||
return stats
|
||||
|
||||
|
||||
# --- CLI wrappers ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def plan(cfg: Config | None = None) -> int:
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
run_row = DaemonRun(daemon="publisher")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
stats = _plan(cfg, sess)
|
||||
run_row.status = "ok"
|
||||
run_row.detail = stats
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def publish_due(cfg: Config | None = None) -> int:
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
run_row = DaemonRun(daemon="publisher")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
_signing(cfg) # no degraded mode
|
||||
except Exception as exc: # noqa: BLE001 — ConfigError
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"refused": str(exc)[:200]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 2
|
||||
stats = _publish_due(cfg, sess)
|
||||
run_row.status = "ok"
|
||||
run_row.detail = stats
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def make_image(item_id: int, cfg: Config | None = None) -> int:
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
item = sess.get(ContentItem, item_id)
|
||||
if item is None:
|
||||
return 1
|
||||
print(_render_image(item, cfg))
|
||||
return 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def approve(item_id: int, actor: str, signature: str | None = None) -> int:
|
||||
from cayceops.db import engine
|
||||
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
return _approve(item_id, actor, sess, signature)
|
||||
finally:
|
||||
engine().dispose()
|
||||
@@ -0,0 +1 @@
|
||||
"""scanner package."""
|
||||
@@ -0,0 +1,209 @@
|
||||
"""scanner — opportunity discovery. Proposes; never executes.
|
||||
|
||||
Sources come from ``CAYCE_OPS_SCANNER_SOURCES`` (JSON array):
|
||||
[{"type": "rss", "url": "https://example.org/feed.xml"},
|
||||
{"type": "page", "url": "https://example.org/partners"}]
|
||||
|
||||
Every discovered candidate becomes a ``proposals`` row with status ``proposed``
|
||||
and a stable ``dedupe_key`` (sha1 of url-or-title) so repeated scans stay
|
||||
idempotent. The scanner performs no side effects beyond writing proposals and
|
||||
audit entries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime
|
||||
from html.parser import HTMLParser
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from cayceops.config import Config
|
||||
from cayceops.db import session_scope
|
||||
from cayceops.models import DaemonRun, Proposal, ProposalSource, ProposalStatus, audit
|
||||
|
||||
# kind classification heuristics (checked in order)
|
||||
_KIND_PATTERNS = [
|
||||
("partnership", re.compile(r"partner|collaborat|integrat", re.I)),
|
||||
("affiliate", re.compile(r"affiliate|referral|commission|revenue\s*share", re.I)),
|
||||
]
|
||||
DEFAULT_KIND = "content_gap"
|
||||
|
||||
|
||||
class _LinkCollector(HTMLParser):
|
||||
"""Minimal HTML parser: page <title> + anchor (href, text) pairs."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.title: str = ""
|
||||
self.links: list[tuple[str, str]] = []
|
||||
self._in_title = False
|
||||
self._in_anchor = False
|
||||
self._href = ""
|
||||
self._text: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str]]) -> None:
|
||||
if tag == "title":
|
||||
self._in_title = True
|
||||
elif tag == "a":
|
||||
self._in_anchor = True
|
||||
self._href = dict(attrs).get("href", "")
|
||||
self._text = []
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "title":
|
||||
self._in_title = False
|
||||
elif tag == "a" and self._in_anchor:
|
||||
self._in_anchor = False
|
||||
text = " ".join("".join(self._text).split())
|
||||
if self._href and text:
|
||||
self.links.append((self._href, text))
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._in_title:
|
||||
self.title += data
|
||||
if self._in_anchor:
|
||||
self._text.append(data)
|
||||
|
||||
|
||||
def _fetch(url: str, cfg: Config, timeout: float = 20.0) -> bytes:
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": cfg.scanner_user_agent}, method="GET"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
||||
return resp.read()
|
||||
|
||||
|
||||
def _parse_rss(body: bytes) -> list[tuple[str, str, str]]:
|
||||
"""Return (title, link, description) tuples from an RSS/Atom feed."""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
root = ElementTree.fromstring(body)
|
||||
ns = {"atom": "http://www.w3.org/2005/Atom"}
|
||||
for item in root.iter("item"): # RSS 2.0
|
||||
title = item.findtext("title") or ""
|
||||
link = item.findtext("link") or ""
|
||||
desc = re.sub(r"<[^>]+>", " ", item.findtext("description") or "")
|
||||
out.append((" ".join(title.split()), link, " ".join(desc.split())[:500]))
|
||||
for entry in root.findall("atom:entry", ns): # Atom
|
||||
title = entry.findtext("atom:title", namespaces=ns) or ""
|
||||
link_el = entry.find("atom:link", ns)
|
||||
link = link_el.get("href", "") if link_el is not None else ""
|
||||
desc = re.sub(r"<[^>]+>", " ", entry.findtext("atom:summary", namespaces=ns) or "")
|
||||
out.append((" ".join(title.split()), link, " ".join(desc.split())[:500]))
|
||||
return out
|
||||
|
||||
|
||||
def _parse_page(body: bytes) -> list[tuple[str, str, str]]:
|
||||
"""Return (title=anchor text, link=href, description=page title) tuples."""
|
||||
parser = _LinkCollector()
|
||||
parser.feed(body.decode("utf-8", errors="replace"))
|
||||
page_title = " ".join(parser.title.split())
|
||||
return [(text, href, f"linked from page: {page_title}") for href, text in parser.links]
|
||||
|
||||
|
||||
def _classify(title: str, description: str) -> str:
|
||||
blob = f"{title}\n{description}"
|
||||
for kind, pattern in _KIND_PATTERNS:
|
||||
if pattern.search(blob):
|
||||
return kind
|
||||
return DEFAULT_KIND
|
||||
|
||||
|
||||
def _score(title: str, description: str, keywords: list[str]) -> float:
|
||||
blob = f"{title}\n{description}".lower()
|
||||
hits = sum(1 for kw in keywords if kw.lower() in blob)
|
||||
return round(min(hits / max(1, len(keywords)), 1.0), 3)
|
||||
|
||||
|
||||
def scan(cfg: Config, sess, fetch=_fetch) -> dict:
|
||||
"""Run one scan pass against all configured sources. Returns run stats."""
|
||||
sources = json.loads(cfg.scanner_sources or "[]")
|
||||
stats = {"sources": len(sources), "candidates": 0, "proposed": 0, "skipped_existing": 0}
|
||||
existing = {
|
||||
key
|
||||
for (key,) in sess.query(Proposal.dedupe_key).filter(
|
||||
Proposal.dedupe_key.isnot(None)
|
||||
)
|
||||
}
|
||||
|
||||
for src in sources:
|
||||
url = src.get("url", "")
|
||||
stype = src.get("type", "page")
|
||||
if not url:
|
||||
continue
|
||||
try:
|
||||
body = fetch(url, cfg)
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
stats.setdefault("source_errors", []).append({"url": url, "error": str(exc)[:200]})
|
||||
continue
|
||||
items = _parse_rss(body) if stype == "rss" else _parse_page(body)
|
||||
stats["candidates"] += len(items)
|
||||
|
||||
for title, link, desc in items:
|
||||
text = title or link
|
||||
if not text:
|
||||
continue
|
||||
keywords = cfg.scanner_keywords
|
||||
score = _score(title or link, desc, keywords)
|
||||
if score <= 0 and stype != "page":
|
||||
continue # feeds must match at least one keyword; pages keep links
|
||||
dedupe = hashlib.sha1((link or title).encode()).hexdigest()[:32] # noqa: S324
|
||||
if dedupe in existing:
|
||||
stats["skipped_existing"] += 1
|
||||
continue
|
||||
proposal = Proposal(
|
||||
source=ProposalSource.SCANNER,
|
||||
kind=_classify(text, desc),
|
||||
title=text[:512],
|
||||
url=(link or None)[:1024] if (link or None) else None,
|
||||
detail={
|
||||
"source_type": stype,
|
||||
"source_url": url,
|
||||
"summary": desc,
|
||||
"scanned_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
match_score=score,
|
||||
status=ProposalStatus.PROPOSED,
|
||||
dedupe_key=dedupe,
|
||||
)
|
||||
sess.add(proposal)
|
||||
sess.flush()
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:scanner",
|
||||
action="propose",
|
||||
proposal_id=proposal.id,
|
||||
detail={"kind": proposal.kind, "score": score, "source": stype},
|
||||
)
|
||||
existing.add(dedupe)
|
||||
stats["proposed"] += 1
|
||||
return stats
|
||||
|
||||
|
||||
def run(cfg: Config | None = None) -> int:
|
||||
"""CLI entry: one scan pass, wrapped in a DaemonRun record."""
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
run_row = DaemonRun(daemon="scanner")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
stats = scan(cfg, sess)
|
||||
except Exception as exc: # noqa: BLE001 — daemon boundary: record and fail cleanly
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"error": str(exc)[:300]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 1
|
||||
run_row.status = "ok"
|
||||
run_row.detail = stats
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
@@ -84,6 +84,15 @@ class Config:
|
||||
enable_http_actions: bool = field(
|
||||
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_HTTP_ACTIONS", False)
|
||||
)
|
||||
# MANDATORY for approval-gated work: the operator x-only pubkey and the
|
||||
# deployment identifier are part of every signed approval message.
|
||||
# Without BOTH, daemons refuse to execute/send/publish — no degraded mode.
|
||||
operator_pubkey: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_OPERATOR_PUBKEY", "")
|
||||
)
|
||||
deployment_id: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_DEPLOYMENT_ID", "")
|
||||
)
|
||||
|
||||
# --- mail ---
|
||||
imap_host: str = field(
|
||||
@@ -162,6 +171,10 @@ class Config:
|
||||
)
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when mandatory configuration is missing."""
|
||||
|
||||
|
||||
def load() -> Config:
|
||||
"""Build the process Config snapshot."""
|
||||
return Config()
|
||||
|
||||
+157
-10
@@ -5,16 +5,26 @@ Status machines
|
||||
proposal: proposed -> approved -> running -> done | failed
|
||||
proposed -> rejected
|
||||
mail: (inbound) classified: approval|rejection|question|other|needs_review
|
||||
(outbound draft) none -> drafted -> approved -> sent | skipped
|
||||
content: draft -> approved -> published
|
||||
(any) draft: none -> drafted -> approved -> sent | skipped
|
||||
content: draft -> approved -> published | skipped (terminal)
|
||||
|
||||
Audit chain
|
||||
-----------
|
||||
audit_log is hash-chained: each entry stores sha256(prev_hash | fields).
|
||||
``cayceops.models.verify_audit_chain`` re-walks it; the monitor surfaces breaks.
|
||||
Not tamper-proof (DB writes can rewrite the chain) but tamper-EVIDENT against
|
||||
selective edits. See AUDIT.md / audit response 2026-10-20.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
@@ -22,6 +32,11 @@ def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _content_hash(text: str) -> str:
|
||||
"""Binding hash for human-approved text (drafts, captions)."""
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
@@ -54,7 +69,8 @@ class Proposal(Base):
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
risk_score: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# keyword-match relevance 0..1 — NOT risk. auto-approve takes highest first.
|
||||
match_score: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
status: Mapped[ProposalStatus] = mapped_column(
|
||||
Enum(ProposalStatus), default=ProposalStatus.PROPOSED, index=True
|
||||
)
|
||||
@@ -62,13 +78,15 @@ class Proposal(Base):
|
||||
dedupe_key: Mapped[str | None] = mapped_column(String(256), unique=True, nullable=True)
|
||||
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# full operator approval signature (128 hex chars) — never truncated
|
||||
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
|
||||
# --- audit -------------------------------------------------------------------
|
||||
# --- audit (hash-chained) ----------------------------------------------------
|
||||
|
||||
|
||||
class AuditEntry(Base):
|
||||
@@ -76,13 +94,21 @@ class AuditEntry(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(128)) # human username | auto:policy | daemon:<name>
|
||||
actor: Mapped[str] = mapped_column(String(128)) # human | auto:policy | daemon:<name>
|
||||
proposal_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(64)) # approve|reject|execute|classify|...
|
||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
result: Mapped[str] = mapped_column(String(32), default="ok") # ok | error
|
||||
# tamper-evident chain
|
||||
prev_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
entry_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
|
||||
# reentrant: several audit() calls may share one transaction
|
||||
_AUDIT_LOCK = threading.RLock()
|
||||
_AUDIT_LOCK_PG_KEY = 7471186757 # pg_advisory_xact_lock key for cross-process
|
||||
|
||||
|
||||
def audit(
|
||||
@@ -93,16 +119,128 @@ def audit(
|
||||
detail: dict | None = None,
|
||||
result: str = "ok",
|
||||
) -> AuditEntry:
|
||||
"""Record one audited action. Every component writes through this helper."""
|
||||
entry = AuditEntry(
|
||||
"""Record one audited action, chained to the previous entry.
|
||||
|
||||
Chain: entry_hash = sha256(prev_hash | ts_epoch | actor | action |
|
||||
proposal_id | detail | result). Writes are SERIALIZED (audit round 2,
|
||||
point 4): pg_advisory_xact_lock on Postgres, in-process lock on SQLite —
|
||||
concurrent writers can no longer fork the chain.
|
||||
|
||||
Legacy rows (entry_hash IS NULL, pre-0002) are ignored when picking
|
||||
prev_hash: the chain starts GENESIS at the first hashed row.
|
||||
"""
|
||||
bind = sess.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
# xact-scoped: PG releases the lock at commit/rollback automatically
|
||||
sess.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:k)"), {"k": _AUDIT_LOCK_PG_KEY}
|
||||
)
|
||||
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
|
||||
entry = _build_entry(prev_hash, actor, action, proposal_id, detail, result)
|
||||
sess.add(entry)
|
||||
return entry
|
||||
|
||||
# SQLite path: hold the in-process lock until the session commits or
|
||||
# rolls back, so concurrent writers cannot read the same prev_hash
|
||||
# (round-2 point 4 — verified by test_concurrent_writers_keep_chain_verifiable)
|
||||
_AUDIT_LOCK.acquire()
|
||||
released = False
|
||||
|
||||
def _release(*_args):
|
||||
nonlocal released
|
||||
if not released:
|
||||
released = True
|
||||
try:
|
||||
_AUDIT_LOCK.release()
|
||||
except RuntimeError: # pragma: no cover
|
||||
pass
|
||||
|
||||
from sqlalchemy import event
|
||||
|
||||
event.listen(sess, "after_commit", _release)
|
||||
event.listen(sess, "after_rollback", _release)
|
||||
event.listen(sess, "after_soft_rollback", _release)
|
||||
try:
|
||||
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
|
||||
entry = _build_entry(prev_hash, actor, action, proposal_id, detail, result)
|
||||
sess.add(entry)
|
||||
return entry
|
||||
except Exception:
|
||||
_release()
|
||||
raise
|
||||
|
||||
|
||||
def _build_entry(
|
||||
prev_hash: str,
|
||||
actor: str,
|
||||
action: str,
|
||||
proposal_id: int | None,
|
||||
detail: dict | None,
|
||||
result: str,
|
||||
) -> AuditEntry:
|
||||
ts = utcnow()
|
||||
ts_int = int(ts.timestamp())
|
||||
detail_json = json.dumps(detail or {}, sort_keys=True, ensure_ascii=False)
|
||||
payload = "|".join(
|
||||
[
|
||||
prev_hash or "GENESIS",
|
||||
str(ts_int),
|
||||
actor,
|
||||
action,
|
||||
str(proposal_id),
|
||||
detail_json,
|
||||
result,
|
||||
]
|
||||
)
|
||||
return AuditEntry(
|
||||
ts=ts,
|
||||
actor=actor,
|
||||
action=action,
|
||||
proposal_id=proposal_id,
|
||||
detail=detail or {},
|
||||
result=result,
|
||||
prev_hash=prev_hash or "GENESIS",
|
||||
entry_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||
)
|
||||
sess.add(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def verify_audit_chain(sess) -> tuple[bool, int | None]:
|
||||
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None).
|
||||
|
||||
Legacy rows (entry_hash IS NULL, written before migration 0002) are
|
||||
skipped and documented: the chain begins GENESIS at the first hashed row,
|
||||
so a migrated database verifies clean without any tampering.
|
||||
"""
|
||||
prev_hash: str | None = None # None = still in legacy prefix
|
||||
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
|
||||
if entry.entry_hash is None:
|
||||
continue # legacy row: outside the chain by documented genesis
|
||||
if prev_hash is None:
|
||||
expected_prev = "GENESIS" # first hashed row anchors the chain
|
||||
else:
|
||||
expected_prev = prev_hash
|
||||
detail_json = json.dumps(entry.detail or {}, sort_keys=True, ensure_ascii=False)
|
||||
ts = entry.ts
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=UTC)
|
||||
payload = "|".join(
|
||||
[
|
||||
expected_prev,
|
||||
str(int(ts.timestamp())),
|
||||
entry.actor,
|
||||
entry.action,
|
||||
str(entry.proposal_id),
|
||||
detail_json,
|
||||
entry.result,
|
||||
]
|
||||
)
|
||||
expected = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
if entry.prev_hash != expected_prev or entry.entry_hash != expected:
|
||||
return False, entry.id
|
||||
prev_hash = entry.entry_hash
|
||||
return True, None
|
||||
|
||||
|
||||
# --- mail --------------------------------------------------------------------
|
||||
@@ -151,6 +289,10 @@ class MailMessage(Base):
|
||||
Enum(DraftStatus), default=DraftStatus.NONE
|
||||
)
|
||||
draft_reply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# binding: hash of the draft text the human approved + who approved it
|
||||
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
@@ -161,6 +303,7 @@ class ContentStatus(enum.StrEnum):
|
||||
DRAFT = "draft"
|
||||
APPROVED = "approved"
|
||||
PUBLISHED = "published"
|
||||
SKIPPED = "skipped" # terminal: disabled channel or attempt budget exhausted
|
||||
|
||||
|
||||
class ContentItem(Base):
|
||||
@@ -179,6 +322,10 @@ class ContentItem(Base):
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
post_ref: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# binding: hash of the caption the human approved
|
||||
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Operator approval signing — mandatory, content- and deployment-bound.
|
||||
|
||||
Every human approval (executor approve / maild approve-draft / publisher
|
||||
approve) must carry a BIP-340 signature made with the operator key:
|
||||
|
||||
message = sha256("cayce-ops:<type>:<deployment_id>:<id>:<content_hash>")
|
||||
|
||||
where content_hash binds the approval to the exact approved content:
|
||||
|
||||
proposal: canonical json of {kind, title, url, detail}
|
||||
mail: canonical json of {to_addr, subject, body}
|
||||
content: canonical json of {channel, caption, link}
|
||||
|
||||
There is NO degraded mode: without CAYCE_OPS_OPERATOR_PUBKEY and
|
||||
CAYCE_OPS_DEPLOYMENT_ID the daemons refuse to execute/send/publish
|
||||
(see cayceops.config.Config.signing_credentials).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
|
||||
class SignatureError(RuntimeError):
|
||||
"""Raised when an approval signature is missing, invalid, or foreign."""
|
||||
|
||||
|
||||
def _canonical(obj: dict) -> str:
|
||||
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def _sha256_hex(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def proposal_content_hash(kind: str, title: str, url: str | None, detail: dict | None) -> str:
|
||||
return _sha256_hex(
|
||||
_canonical(
|
||||
{
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"detail": detail or {},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def mail_content_hash(to_addr: str | None, subject: str | None, body: str | None) -> str:
|
||||
return _sha256_hex(
|
||||
_canonical(
|
||||
{
|
||||
"to_addr": to_addr,
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def content_content_hash(channel: str, caption: str, link: str | None) -> str:
|
||||
return _sha256_hex(
|
||||
_canonical(
|
||||
{
|
||||
"channel": channel,
|
||||
"caption": caption,
|
||||
"link": link,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def approval_message(
|
||||
item_type: str, deployment_id: str, item_id: int, content_hash: str
|
||||
) -> bytes:
|
||||
"""The exact 32-byte message the operator signs."""
|
||||
raw = f"cayce-ops:{item_type}:{deployment_id}:{item_id}:{content_hash}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).digest()
|
||||
|
||||
|
||||
def verify_signature(
|
||||
item_type: str,
|
||||
deployment_id: str,
|
||||
item_id: int,
|
||||
content_hash: str,
|
||||
signature_hex: str | None,
|
||||
operator_pubkey_hex: str,
|
||||
) -> bool:
|
||||
"""Verify an operator approval signature. Foreign deployments or edited
|
||||
content verify False because both are inside the signed message."""
|
||||
if not signature_hex or not operator_pubkey_hex or not deployment_id:
|
||||
return False
|
||||
from cayceops.components.publisher.nostr import _schnorr_verify
|
||||
|
||||
try:
|
||||
return _schnorr_verify(
|
||||
bytes.fromhex(signature_hex),
|
||||
approval_message(item_type, deployment_id, item_id, content_hash),
|
||||
bytes.fromhex(operator_pubkey_hex),
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
@@ -0,0 +1,180 @@
|
||||
-- cayce-ops — least-privilege per-daemon roles (audit round 2, point 3)
|
||||
--
|
||||
-- Apply with psql and MANDATORY password variables; the script refuses to run
|
||||
-- without them (no literal credentials):
|
||||
--
|
||||
-- psql -h 127.0.0.1 -U postgres -d cayce_ops -f deploy/roles.sql \
|
||||
-- -v scanner_password='…' -v executor_password='…' \
|
||||
-- -v maild_password='…' -v publisher_password='…' \
|
||||
-- -v monitor_password='…' -v approver_password='…'
|
||||
--
|
||||
-- Model:
|
||||
-- cayce_scanner : INSERT proposals only (+ INSERT audit_log, daemon_runs)
|
||||
-- cayce_executor : UPDATE proposals WITHOUT approved/signature columns
|
||||
-- cayce_maild : UPDATE mail_messages WITHOUT approved/signature columns
|
||||
-- cayce_publisher : INSERT content_items, UPDATE WITHOUT approved/signature
|
||||
-- cayce_monitor : SELECT everything (+ INSERT audit_log for anchors)
|
||||
-- cayce_approver : THE ONLY role that can write status='approved',
|
||||
-- approved_hash, operator_signature, approved_by/at
|
||||
--
|
||||
-- A BEFORE trigger rejects status='approved' writes from any role except
|
||||
-- cayce_approver — column grants alone cannot express that condition.
|
||||
--
|
||||
-- NOTE: do NOT apply on production yourself; the operator does.
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
\if :{?scanner_password}
|
||||
\else
|
||||
\echo 'ERROR: missing variables. Pass -v scanner_password=… executor_password=… maild_password=… publisher_password=… monitor_password=… approver_password=…'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
\if :{?executor_password}
|
||||
\else
|
||||
\echo 'ERROR: missing executor_password'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
\if :{?maild_password}
|
||||
\else
|
||||
\echo 'ERROR: missing maild_password'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
\if :{?publisher_password}
|
||||
\else
|
||||
\echo 'ERROR: missing publisher_password'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
\if :{?monitor_password}
|
||||
\else
|
||||
\echo 'ERROR: missing monitor_password'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
\if :{?approver_password}
|
||||
\else
|
||||
\echo 'ERROR: missing approver_password'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
|
||||
-- idempotency: detach old incarnations before dropping
|
||||
DROP FUNCTION IF EXISTS cayce_ops_approval_gate() CASCADE;
|
||||
REVOKE ALL ON SCHEMA public FROM
|
||||
cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM
|
||||
cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM
|
||||
cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
|
||||
DROP ROLE IF EXISTS cayce_scanner;
|
||||
DROP ROLE IF EXISTS cayce_executor;
|
||||
DROP ROLE IF EXISTS cayce_maild;
|
||||
DROP ROLE IF EXISTS cayce_publisher;
|
||||
DROP ROLE IF EXISTS cayce_monitor;
|
||||
DROP ROLE IF EXISTS cayce_approver;
|
||||
|
||||
CREATE ROLE cayce_scanner LOGIN PASSWORD :'scanner_password';
|
||||
CREATE ROLE cayce_executor LOGIN PASSWORD :'executor_password';
|
||||
CREATE ROLE cayce_maild LOGIN PASSWORD :'maild_password';
|
||||
CREATE ROLE cayce_publisher LOGIN PASSWORD :'publisher_password';
|
||||
CREATE ROLE cayce_monitor LOGIN PASSWORD :'monitor_password';
|
||||
CREATE ROLE cayce_approver LOGIN PASSWORD :'approver_password';
|
||||
|
||||
GRANT USAGE ON SCHEMA public TO cayce_scanner, cayce_executor, cayce_maild,
|
||||
cayce_publisher, cayce_monitor, cayce_approver;
|
||||
|
||||
-- sequences everybody needs
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public
|
||||
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
|
||||
-- common reads
|
||||
GRANT SELECT ON proposals, audit_log, mail_messages, content_items, daemon_runs
|
||||
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
|
||||
-- every daemon appends to audit_log and daemon_runs (never UPDATE/DELETE)
|
||||
GRANT INSERT ON audit_log
|
||||
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
GRANT INSERT ON daemon_runs
|
||||
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor;
|
||||
|
||||
-- scanner: propose only
|
||||
GRANT INSERT ON proposals TO cayce_scanner;
|
||||
|
||||
-- executor: transition proposals WITHOUT touching approval columns
|
||||
GRANT UPDATE (status, attempts, updated_at) ON proposals TO cayce_executor;
|
||||
-- (there is no error column; adjust if added) — see trigger below for status
|
||||
|
||||
-- maild: classify + drafts + send bookkeeping WITHOUT approval columns
|
||||
GRANT INSERT, UPDATE (classification, confidence, draft_status, draft_reply,
|
||||
in_reply_to, message_id, from_addr, to_addr, subject, body, processed_at)
|
||||
ON mail_messages TO cayce_maild;
|
||||
|
||||
-- publisher: plan drafts and record publishes WITHOUT approval columns
|
||||
GRANT INSERT ON content_items TO cayce_publisher;
|
||||
GRANT UPDATE (status, attempts, error, media_path, scheduled_for, published_at, post_ref)
|
||||
ON content_items TO cayce_publisher;
|
||||
|
||||
-- approver: the ONLY writer of approvals and signatures
|
||||
GRANT SELECT, UPDATE ON proposals TO cayce_approver;
|
||||
GRANT SELECT, UPDATE ON mail_messages TO cayce_approver;
|
||||
GRANT SELECT, UPDATE ON content_items TO cayce_approver;
|
||||
GRANT INSERT ON audit_log TO cayce_approver;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- approval gate: status/draft_status='approved' and signature columns may
|
||||
-- only be written by cayce_approver (one trigger per table — column sets differ)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION cayce_ops_gate_proposals() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF current_user IN ('cayce_approver', 'postgres') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.status::text = 'APPROVED' THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may set proposals.status=approved (user=%)', current_user;
|
||||
END IF;
|
||||
IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION cayce_ops_gate_mail() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF current_user IN ('cayce_approver', 'postgres') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.draft_status::text = 'APPROVED' THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may set mail draft_status=approved (user=%)', current_user;
|
||||
END IF;
|
||||
IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION cayce_ops_gate_content() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF current_user IN ('cayce_approver', 'postgres') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.status::text = 'APPROVED' THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may set content status=approved (user=%)', current_user;
|
||||
END IF;
|
||||
IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS proposals_approval_gate ON proposals;
|
||||
CREATE TRIGGER proposals_approval_gate BEFORE INSERT OR UPDATE ON proposals
|
||||
FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_proposals();
|
||||
|
||||
DROP TRIGGER IF EXISTS mail_approval_gate ON mail_messages;
|
||||
CREATE TRIGGER mail_approval_gate BEFORE INSERT OR UPDATE ON mail_messages
|
||||
FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_mail();
|
||||
|
||||
DROP TRIGGER IF EXISTS content_approval_gate ON content_items;
|
||||
CREATE TRIGGER content_approval_gate BEFORE INSERT OR UPDATE ON content_items
|
||||
FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_content();
|
||||
@@ -9,9 +9,14 @@ sudo -u cayce uv sync --frozen
|
||||
sudo -u cayce cayceops db upgrade # alembic to head
|
||||
sudo cp deploy/systemd/cayce-ops@.service deploy/systemd/cayce-ops@.timer /etc/systemd/system/
|
||||
|
||||
# Every daemon has a `run` subcommand now:
|
||||
# scanner run · executor run · maild run (poll + send-approved)
|
||||
# publisher run (publish-due) · monitor run (build)
|
||||
# cadence per daemon (edit OnCalendar in each timer copy):
|
||||
# scanner hourly executor */10 min maild */5 min
|
||||
# publisher */5 min monitor */1 min
|
||||
# NOTE: `publisher plan` (fills the calendar) is a SEPARATE timer:
|
||||
# OnCalendar=daily -> ExecStart=/srv/cayce-ops/.venv/bin/cayceops publisher plan
|
||||
for d in scanner executor maild publisher monitor; do
|
||||
sudo cp deploy/systemd/cayce-ops@.timer /etc/systemd/system/cayce-ops@$d.timer
|
||||
done
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=cayce-ops publisher plan (fills the content calendar, daily)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=cayce
|
||||
WorkingDirectory=/srv/cayce-ops
|
||||
EnvironmentFile=/srv/cayce-ops/.env
|
||||
ExecStart=/srv/cayce-ops/.venv/bin/cayceops publisher plan
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/srv/cayce-ops
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Run cayce-ops publisher plan daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 06:00:00
|
||||
RandomizedDelaySec=300
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+1
-1
@@ -6,7 +6,7 @@ services:
|
||||
POSTGRES_PASSWORD: localdev-only-not-a-secret
|
||||
POSTGRES_DB: cayce_ops
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432" # loopback only — Docker bypasses host firewalls
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
|
||||
@@ -69,3 +69,51 @@ An action plugin is a Python function registered in
|
||||
`http_post` exists but is inert unless `CAYCE_OPS_ENABLE_HTTP_ACTIONS=true`.
|
||||
*Cost:* no sandboxing of plugins — plugins are repo code, reviewed like
|
||||
everything else.
|
||||
|
||||
## D10 — Audit chain is hash-chained; approval can require operator signature (post-audit)
|
||||
`audit_log` rows chain sha256(prev_hash|fields); `verify_audit_chain` walks it,
|
||||
the monitor can surface breaks. With `CAYCE_OPS_OPERATOR_PUBKEY` set,
|
||||
`executor approve` REQUIRES a BIP-340 signature over
|
||||
sha256("cayce-ops:approve:<id>") from the operator key — `--actor` becomes a
|
||||
label, the key is the proof (the audit's "anyone can sign as anyone").
|
||||
*Cost:* without a pubkey configured the gate degrades to the old free-text
|
||||
actor; separate DB roles per daemon remain a deployment TODO (SQL to tighten
|
||||
ships in deploy/roles.sql).
|
||||
|
||||
## D11 — Mail threading now has a real first-contact path (post-audit)
|
||||
Sends create an OUTBOUND row (own Message-ID, In-Reply-To -> inbound);
|
||||
inbound rows keep their original Message-ID. `maild compose` creates the
|
||||
initial outreach draft for a proposal. Approval is bound to the draft text
|
||||
via sha256 (`approved_hash`): an edited draft refuses to send until
|
||||
re-approved. Each send commits in its own transaction — SMTP failure on
|
||||
message N can no longer resurrect 1..N-1 as duplicates.
|
||||
|
||||
## D12 — IMAP \\Seen only after commit; content gates tighten (post-audit)
|
||||
Messages are fetched unmarked and flagged \\Seen after the processing
|
||||
transaction commits (crash mid-batch no longer loses mail). Publisher:
|
||||
caption hash-bound at approval, disabled channels are terminal (`SKIPPED`),
|
||||
publish failures burn a bounded attempt budget before abandonment.
|
||||
`risk_score` renamed `match_score` (keyword relevance) and auto-approval
|
||||
takes the HIGHEST score first.
|
||||
|
||||
## D13 — No non-human approval path (round 2, supersedes D5)
|
||||
Operator signatures are mandatory for approvals AND re-verified at
|
||||
execution/send/publish time against current content + deployment id. With
|
||||
that gate, the AUTONOMOUS_MODE auto-approval path cannot exist: it would
|
||||
produce unsigned approvals the gate refuses. `_auto_approve` is inert and
|
||||
audits `autonomy_disabled` when invoked. *Cost:* AUTONOMOUS_MODE is now
|
||||
inert by design.
|
||||
|
||||
## D14 — Signatures bind content + deployment (round 2)
|
||||
message = sha256("cayce-ops:<type>:<deployment_id>:<id>:<content_hash>").
|
||||
content_hash is canonical-json of the fields that matter per type. Full
|
||||
signatures live on the row (operator_signature) and in the audit detail.
|
||||
Verification happens again at run time: unsigned rows (raw SQL approval)
|
||||
revert to their pre-approval state with an error audit. *Cost:* a deployment
|
||||
id env var is mandatory; signatures must be recomputed if content changes.
|
||||
|
||||
## D15 — Approval classifier veto (round 2)
|
||||
Any negation token anywhere in a mail vetoes an `approval` classification →
|
||||
needs_review. Simpler and strictly conservative vs. per-pattern negation
|
||||
lists. *Cost:* genuinely-positive approvals containing stray negations
|
||||
(e.g. "we have no reason not to welcome you") need human triage.
|
||||
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# The gate, wired as a real hook (audit medium finding: it did not self-install).
|
||||
# Runs the exact CI pipeline; refuses the push on any failure.
|
||||
set -e
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
make check
|
||||
echo "pre-push gate passed — safe to push"
|
||||
@@ -10,8 +10,14 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from coincurve import PublicKey
|
||||
|
||||
# test operator keypair (invented; allowlisted in .gitleaks.toml)
|
||||
OPERATOR_SECRET = bytes.fromhex("22" * 32)
|
||||
OPERATOR_PUBKEY = PublicKey.from_secret(OPERATOR_SECRET).format().hex()[2:]
|
||||
|
||||
# Config must see the test DB before cayceops.config/db get imported.
|
||||
os.environ.setdefault("CAYCE_OPS_DATABASE_URL", "sqlite:///:memory:")
|
||||
@@ -54,6 +60,61 @@ def sess(db_engine):
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def signer():
|
||||
"""Signs approval messages with the test operator key (22*32, deployment
|
||||
'itest'). Mirrors exactly what the operator CLI does."""
|
||||
from coincurve import PrivateKey
|
||||
|
||||
from cayceops.models import MailDirection
|
||||
from cayceops.signing import (
|
||||
approval_message,
|
||||
content_content_hash,
|
||||
mail_content_hash,
|
||||
proposal_content_hash,
|
||||
)
|
||||
|
||||
secret = OPERATOR_SECRET
|
||||
|
||||
def _sig(msg: bytes) -> str:
|
||||
return PrivateKey(secret).sign_schnorr(msg, aux_randomness=b"\x00" * 32).hex()
|
||||
|
||||
def proposal(p, deployment="itest"):
|
||||
chash = proposal_content_hash(p.kind, p.title, p.url, p.detail)
|
||||
return _sig(approval_message("proposal", deployment, p.id, chash))
|
||||
|
||||
def mail(record, deployment="itest"):
|
||||
to = (
|
||||
record.from_addr
|
||||
if record.direction == MailDirection.INBOUND
|
||||
else record.to_addr
|
||||
)
|
||||
chash = mail_content_hash(to, record.subject, record.draft_reply)
|
||||
return _sig(approval_message("mail", deployment, record.id, chash))
|
||||
|
||||
def content(item, deployment="itest"):
|
||||
chash = content_content_hash(item.channel, item.caption, item.link)
|
||||
return _sig(approval_message("content", deployment, item.id, chash))
|
||||
|
||||
def raw(sig_bytes: bytes) -> str:
|
||||
return _sig(sig_bytes)
|
||||
|
||||
def msg(item_type: str, deployment: str, item_id: int, chash: str) -> bytes:
|
||||
from cayceops.signing import approval_message
|
||||
|
||||
return approval_message(item_type, deployment, item_id, chash)
|
||||
|
||||
def content_hash(item, deployment="itest") -> str:
|
||||
from cayceops.signing import content_content_hash
|
||||
|
||||
return content_content_hash(item.channel, item.caption, item.link)
|
||||
|
||||
return SimpleNamespace(
|
||||
proposal=proposal, mail=mail, content=content, raw=raw, msg=msg,
|
||||
content_hash=content_hash,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def cfg(monkeypatch):
|
||||
"""A Config snapshot with test defaults (no network, no autonomy)."""
|
||||
@@ -66,6 +127,10 @@ def cfg(monkeypatch):
|
||||
"CAYCE_OPS_ENABLE_INSTAGRAM": "false",
|
||||
"CAYCE_OPS_NOSTR_RELAYS": "",
|
||||
"CAYCE_OPS_MEDIA_DIR": "media",
|
||||
# signing defaults (audit round 2): tests operate in a signed
|
||||
# deployment unless they explicitly clear these
|
||||
"CAYCE_OPS_DEPLOYMENT_ID": "itest",
|
||||
"CAYCE_OPS_OPERATOR_PUBKEY": OPERATOR_PUBKEY,
|
||||
}
|
||||
|
||||
def _apply(**overrides):
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Integration tests against Postgres 16: roles.sql + one full daemon cycle
|
||||
per role (audit round 2, point 3).
|
||||
|
||||
Skipped unless a Postgres is reachable at TEST_PG_DSN (default: the
|
||||
docker-compose service). Roles are applied by piping deploy/roles.sql
|
||||
through psql WITH the mandatory -v password variables — exercising the same
|
||||
path an operator uses; the test skips if the psql client is not installed.
|
||||
|
||||
Run: uv run pytest -m integration -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
PG_DSN = os.environ.get(
|
||||
"TEST_PG_DSN",
|
||||
"postgresql://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops",
|
||||
)
|
||||
ROLES_SQL = Path(__file__).resolve().parents[2] / "deploy" / "roles.sql"
|
||||
|
||||
ROLE_PASSWORDS = {
|
||||
"scanner_password": "itest-scanner-pw",
|
||||
"executor_password": "itest-executor-pw",
|
||||
"maild_password": "itest-maild-pw",
|
||||
"publisher_password": "itest-publisher-pw",
|
||||
"monitor_password": "itest-monitor-pw",
|
||||
"approver_password": "itest-approver-pw",
|
||||
}
|
||||
|
||||
|
||||
def _pg_available() -> bool:
|
||||
try:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(PG_DSN, connect_timeout=3):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _psql_available() -> bool:
|
||||
try:
|
||||
return (
|
||||
subprocess.run(
|
||||
["psql", "--version"], capture_output=True, timeout=10
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def roles_applied():
|
||||
if not _pg_available():
|
||||
pytest.skip("Postgres not reachable (set TEST_PG_DSN)")
|
||||
if not _psql_available():
|
||||
pytest.skip("psql client not installed")
|
||||
|
||||
# fresh slate on an admin connection
|
||||
import psycopg
|
||||
|
||||
admin = psycopg.connect(PG_DSN, autocommit=True)
|
||||
with admin.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
DROP TABLE IF EXISTS audit_log, mail_messages, content_items,
|
||||
daemon_runs, proposals, alembic_version CASCADE;
|
||||
DROP TYPE IF EXISTS proposalsource, proposalstatus, maildirection,
|
||||
mailclassification, draftstatus, contentstatus CASCADE;
|
||||
DROP FUNCTION IF EXISTS cayce_ops_approval_gate() CASCADE;
|
||||
"""
|
||||
)
|
||||
admin.close()
|
||||
|
||||
# apply alembic chain (imports app; config must point at PG first)
|
||||
os.environ["CAYCE_OPS_DATABASE_URL"] = PG_DSN.replace(
|
||||
"postgresql://", "postgresql+psycopg://"
|
||||
)
|
||||
import subprocess as sp
|
||||
|
||||
env = {**os.environ, "CAYCE_OPS_DATABASE_URL": os.environ["CAYCE_OPS_DATABASE_URL"]}
|
||||
# upgrade -> downgrade -> upgrade round-trip on Postgres (audit round 2,
|
||||
# point 6): exercises 0002.downgrade and 0003.downgrade for real
|
||||
sp.run(["uv", "run", "alembic", "upgrade", "head"], check=True, env=env)
|
||||
sp.run(["uv", "run", "alembic", "downgrade", "0001"], check=True, env=env)
|
||||
sp.run(["uv", "run", "alembic", "upgrade", "head"], check=True, env=env)
|
||||
|
||||
# apply roles.sql the operator way: psql -v <password vars>
|
||||
cmd = ["psql", PG_DSN, "-v", "ON_ERROR_STOP=1", "-f", str(ROLES_SQL)]
|
||||
for var, value in ROLE_PASSWORDS.items():
|
||||
cmd += ["-v", f"{var}={value}"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode == 0, f"roles.sql failed:\n{result.stderr}"
|
||||
|
||||
# re-apply WITHOUT variables must fail (audit requirement: no silent run)
|
||||
bad = subprocess.run(
|
||||
["psql", PG_DSN, "-v", "ON_ERROR_STOP=1", "-f", str(ROLES_SQL)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert bad.returncode != 0, "roles.sql must refuse to run without vars"
|
||||
|
||||
yield
|
||||
|
||||
|
||||
def _connect_as(role: str):
|
||||
"""Connect with the role's credentials on the same DB as PG_DSN."""
|
||||
import psycopg
|
||||
|
||||
key = f"{role.split('_', 1)[1]}_password"
|
||||
after_scheme = PG_DSN.split("://", 1)[1]
|
||||
userinfo, hostport_db = after_scheme.split("@", 1)
|
||||
hostport, _db = hostport_db.split("/", 1)
|
||||
dsn = f"postgresql://{role}:{ROLE_PASSWORDS[key]}@{hostport}/{_db}"
|
||||
return psycopg.connect(dsn, autocommit=True)
|
||||
|
||||
|
||||
def _must_fail(conn, sql: str, because: str):
|
||||
import psycopg
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
except psycopg.Error as exc:
|
||||
assert because.lower() in str(exc).lower(), f"{because} — got: {exc}"
|
||||
conn.rollback()
|
||||
return
|
||||
pytest.fail(f"expected failure: {because}")
|
||||
|
||||
|
||||
def test_roles_full_daemon_cycle(roles_applied):
|
||||
|
||||
# --- scanner role: proposes (INSERT proposals) ---
|
||||
with _connect_as("cayce_scanner") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO proposals (source, kind, title, url, detail,
|
||||
match_score, status, attempts, created_at, updated_at)
|
||||
VALUES ('SCANNER', 'partnership', 'Acme partner', NULL, '{}',
|
||||
0.9, 'PROPOSED', 0, now(), now()) RETURNING id"""
|
||||
)
|
||||
proposal_id = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"INSERT INTO audit_log (ts, actor, action, detail, result) "
|
||||
"VALUES (now(), 'daemon:scanner', 'propose', '{}', 'ok')"
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO daemon_runs (daemon, started_at, status, detail) "
|
||||
"VALUES ('scanner', now(), 'ok', '{}')"
|
||||
)
|
||||
|
||||
# scanner cannot approve: no UPDATE grant at all, and the trigger would
|
||||
# still block status=APPROVED for non-approver roles
|
||||
scanner = _connect_as("cayce_scanner")
|
||||
_must_fail(
|
||||
scanner,
|
||||
f"UPDATE proposals SET status='APPROVED' WHERE id={proposal_id}",
|
||||
because="permission denied",
|
||||
)
|
||||
scanner.close()
|
||||
|
||||
# executor cannot approve either (trigger), and cannot write signatures
|
||||
executor = _connect_as("cayce_executor")
|
||||
# the signature column is outside the executor's column grant: the whole
|
||||
# UPDATE is denied before the trigger even runs
|
||||
fake_sig = "ab" * 64 # invented 128-hex signature
|
||||
_must_fail(
|
||||
executor,
|
||||
f"UPDATE proposals SET status='APPROVED', operator_signature='{fake_sig}' "
|
||||
f"WHERE id={proposal_id}",
|
||||
because="permission denied",
|
||||
)
|
||||
# executor CAN run its cycle once the approver approved
|
||||
with executor.cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE proposals SET status='RUNNING', attempts=attempts+1, "
|
||||
f"updated_at=now() WHERE id={proposal_id}"
|
||||
)
|
||||
cur.execute(
|
||||
f"UPDATE proposals SET status='DONE', updated_at=now() WHERE id={proposal_id}"
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO daemon_runs (daemon, started_at, status, detail) "
|
||||
"VALUES ('executor', now(), 'ok', '{}')"
|
||||
)
|
||||
executor.close()
|
||||
|
||||
# --- approver: the only role that can approve + sign ---
|
||||
sig = "c9" * 64 # invented 128-hex signature for the permission test
|
||||
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE proposals SET status='APPROVED', approved_by='gustavo', "
|
||||
f"approved_at=now(), operator_signature='{sig}' WHERE id={proposal_id}"
|
||||
)
|
||||
|
||||
# --- maild role: drafts + sends, cannot approve ---
|
||||
with _connect_as("cayce_maild") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO mail_messages (direction, message_id, from_addr,
|
||||
subject, body, draft_reply, draft_status, confidence)
|
||||
VALUES ('OUTBOUND', '<itest@x>', 'desk@acme.example.org', 'Hello',
|
||||
'Body', 'Body', 'DRAFTED', 0) RETURNING id"""
|
||||
)
|
||||
mail_id = cur.fetchone()[0]
|
||||
maild = _connect_as("cayce_maild")
|
||||
_must_fail(
|
||||
maild,
|
||||
f"UPDATE mail_messages SET draft_status='APPROVED' WHERE id={mail_id}",
|
||||
because="only cayce_approver may set mail draft_status=approved",
|
||||
)
|
||||
_must_fail(
|
||||
maild,
|
||||
f"UPDATE mail_messages SET operator_signature='{sig}' WHERE id={mail_id}",
|
||||
because="permission denied", # signature column outside maild's grant
|
||||
)
|
||||
# the approver approves; then maild can mark it sent
|
||||
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE mail_messages SET draft_status='APPROVED', "
|
||||
"approved_by='gustavo', approved_hash='"
|
||||
+ "a" * 64
|
||||
+ f"', operator_signature='{sig}' WHERE id={mail_id}"
|
||||
)
|
||||
with _connect_as("cayce_maild") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE mail_messages SET draft_status='SENT' WHERE id={mail_id}"
|
||||
)
|
||||
maild.close()
|
||||
|
||||
# --- publisher role: plans content, cannot approve ---
|
||||
with _connect_as("cayce_publisher") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO content_items (theme, channel, caption, status,
|
||||
attempts, created_at)
|
||||
VALUES ('atm-fees', 'nostr', 'caption', 'DRAFT', 0, now())
|
||||
RETURNING id"""
|
||||
)
|
||||
item_id = cur.fetchone()[0]
|
||||
publisher = _connect_as("cayce_publisher")
|
||||
_must_fail(
|
||||
publisher,
|
||||
f"UPDATE content_items SET status='APPROVED' WHERE id={item_id}",
|
||||
because="only cayce_approver may set content status=approved",
|
||||
)
|
||||
# approver approves; publisher publishes
|
||||
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE content_items SET status='APPROVED', approved_hash='"
|
||||
+ "b" * 64
|
||||
+ f"', operator_signature='{sig}' WHERE id={item_id}"
|
||||
)
|
||||
with _connect_as("cayce_publisher") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE content_items SET status='PUBLISHED', published_at=now() "
|
||||
f"WHERE id={item_id}"
|
||||
)
|
||||
publisher.close()
|
||||
|
||||
# --- monitor role: read-only + audit anchors ---
|
||||
monitor = _connect_as("cayce_monitor")
|
||||
with monitor.cursor() as cur:
|
||||
cur.execute("SELECT count(*) FROM proposals")
|
||||
cur.fetchone()
|
||||
cur.execute(
|
||||
"INSERT INTO audit_log (ts, actor, action, detail, result) "
|
||||
"VALUES (now(), 'daemon:monitor', 'anchor', '{}', 'ok')"
|
||||
)
|
||||
# monitor CAN book its runs, but cannot touch proposals
|
||||
with monitor.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO daemon_runs (daemon, started_at, status, detail) "
|
||||
"VALUES ('monitor', now(), 'ok', '{}')"
|
||||
)
|
||||
_must_fail(
|
||||
monitor,
|
||||
"INSERT INTO proposals (source, kind, title, detail, match_score, status, "
|
||||
"attempts, created_at, updated_at) "
|
||||
"VALUES ('SCANNER', 'other', 'x', '{}', 0, 'PROPOSED', 0, now(), now())",
|
||||
because="permission denied",
|
||||
)
|
||||
monitor.close()
|
||||
@@ -0,0 +1,242 @@
|
||||
"""executor tests — human gate (signed), autonomy bounds, retries, audit chain.
|
||||
|
||||
Since audit round 2, approvals REQUIRE an operator signature bound to the
|
||||
proposal's current content + deployment id (tests sign with the conftest
|
||||
operator key 22*32, deployment 'itest').
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cayceops.components.executor import executor, plugins
|
||||
from cayceops.models import AuditEntry, Proposal, ProposalStatus
|
||||
|
||||
|
||||
def _proposal(kind="content_gap", title="Test item", **kw):
|
||||
return Proposal(kind=kind, title=title, **kw)
|
||||
|
||||
|
||||
def test_approve_requires_proposed_state(cfg, sess, signer):
|
||||
p = _proposal()
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
# second approve must fail (state is now approved)
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 1
|
||||
)
|
||||
assert p.approved_by == "gustavo"
|
||||
assert p.status is ProposalStatus.APPROVED
|
||||
# FULL signature stored on the row
|
||||
assert p.operator_signature and len(p.operator_signature) == 128
|
||||
|
||||
|
||||
def test_reject_records_actor(cfg, sess):
|
||||
p = _proposal()
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
assert executor.reject(p.id, "gustavo", cfg(), sess) == 0
|
||||
assert p.status is ProposalStatus.REJECTED
|
||||
|
||||
|
||||
def test_run_executes_only_approved(cfg, sess, signer):
|
||||
approved = _proposal(title="Approved item")
|
||||
proposed = _proposal(title="Proposed item")
|
||||
sess.add_all([approved, proposed])
|
||||
sess.flush()
|
||||
assert (
|
||||
executor.approve(
|
||||
approved.id, "gustavo", cfg(), sess, signature=signer.proposal(approved)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
config = cfg() # AUTONOMOUS_MODE=false
|
||||
assert executor.run(config, sess) == 0
|
||||
|
||||
assert approved.status is ProposalStatus.DONE
|
||||
assert proposed.status is ProposalStatus.PROPOSED # untouched
|
||||
|
||||
acts = [a.action for a in sess.query(AuditEntry).order_by(AuditEntry.id).all()]
|
||||
assert "execute_start" in acts and "execute_done" in acts
|
||||
assert acts.count("approve") == 1
|
||||
|
||||
|
||||
def test_autonomy_off_by_default(cfg, sess):
|
||||
p = _proposal(kind="content_gap", title="Never auto-runs")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
config = cfg() # autonomous_mode False even with kinds configured
|
||||
executor._auto_approve(config, sess)
|
||||
assert p.status is ProposalStatus.PROPOSED
|
||||
|
||||
|
||||
def test_autonomy_cannot_approve_anymore(cfg, sess):
|
||||
"""Round-2 1a: with mandatory signatures there is NO non-human approval
|
||||
path. AUTONOMOUS_MODE no longer approves anything (D13)."""
|
||||
low = _proposal(kind="content_gap", title="barely relevant")
|
||||
low.match_score = 0.1
|
||||
high = _proposal(kind="content_gap", title="highly relevant")
|
||||
high.match_score = 0.9
|
||||
sess.add_all([low, high])
|
||||
sess.flush()
|
||||
config = cfg(
|
||||
CAYCE_OPS_AUTONOMOUS_MODE="true",
|
||||
CAYCE_OPS_AUTONOMOUS_KINDS="content_gap",
|
||||
CAYCE_OPS_AUTONOMOUS_DAILY_CAP="1",
|
||||
)
|
||||
assert executor._auto_approve(config, sess) == 0
|
||||
assert low.status is ProposalStatus.PROPOSED
|
||||
assert high.status is ProposalStatus.PROPOSED
|
||||
entry = sess.query(AuditEntry).filter_by(action="autonomy_disabled").one()
|
||||
assert entry.result == "error"
|
||||
|
||||
|
||||
def test_signed_approval_mandatory_and_content_bound(cfg, sess, signer):
|
||||
"""Round-2 1a/1b/1c: signature mandatory, bound to content+deployment,
|
||||
stored COMPLETE on the row and in the audit log. Signatures for other
|
||||
deployments or other content are refused."""
|
||||
from cayceops.signing import proposal_content_hash
|
||||
|
||||
p = _proposal(kind="partnership", title="Acme partner", url="https://acme.example.org")
|
||||
p.detail = {"region": "latam"}
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
chash = proposal_content_hash(p.kind, p.title, p.url, p.detail)
|
||||
|
||||
# no signature -> refused
|
||||
assert executor.approve(p.id, "gustavo", cfg(), sess, signature=None) == 2
|
||||
# signature for ANOTHER deployment -> refused (required test)
|
||||
other_dep = signer.raw(
|
||||
signer.msg("proposal", "other-deploy", p.id, chash)
|
||||
)
|
||||
assert executor.approve(p.id, "gustavo", cfg(), sess, signature=other_dep) == 2
|
||||
# signature over OTHER CONTENT -> refused (required test)
|
||||
other_content = signer.raw(signer.msg("proposal", "itest", p.id, "00" * 32))
|
||||
assert executor.approve(p.id, "gustavo", cfg(), sess, signature=other_content) == 2
|
||||
# valid signature -> approved
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
assert p.status is ProposalStatus.APPROVED
|
||||
entry = sess.query(AuditEntry).filter_by(action="approve", result="ok").one()
|
||||
assert entry.detail["signature"] == p.operator_signature
|
||||
|
||||
|
||||
def test_approve_fails_audited_from_cli_wrapper(cfg, sess):
|
||||
"""Round-2 1f: signature failures leave an audit record even when the
|
||||
command runs through its CLI wrapper (the wrapper passes sess)."""
|
||||
p = _proposal()
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
code = executor.approve(p.id, "gustavo", cfg(), sess, signature=None)
|
||||
assert code == 2
|
||||
entry = sess.query(AuditEntry).filter_by(action="approve", result="error").one()
|
||||
assert "signature required" in entry.detail["reason"]
|
||||
|
||||
|
||||
def test_sql_approved_row_is_refused_at_execution(cfg, sess, monkeypatch):
|
||||
"""Round-2 1d (required test): a row put in `approved` by raw SQL without
|
||||
a signature must not execute; it reverts to `proposed` with an error audit."""
|
||||
p = _proposal()
|
||||
p.status = ProposalStatus.APPROVED # raw-SQL style: no signature
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
executed = []
|
||||
|
||||
def spy_plugin(proposal, cfg, sess):
|
||||
executed.append(proposal.id)
|
||||
return {}
|
||||
|
||||
monkeypatch.setitem(plugins.REGISTRY, "content_gap", spy_plugin)
|
||||
assert executor.run(cfg(), sess) == 0
|
||||
assert executed == []
|
||||
assert p.status is ProposalStatus.PROPOSED # reverted
|
||||
refused = sess.query(AuditEntry).filter_by(action="execute_refused").one()
|
||||
assert refused.result == "error"
|
||||
|
||||
|
||||
def test_execution_verifies_signature_against_current_content(cfg, sess, signer, monkeypatch):
|
||||
"""Round-2 1d: signature over content that changed after approval is
|
||||
refused at execution time."""
|
||||
p = _proposal(kind="partnership", title="original")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
p.title = "EDITED after approval" # content changed under the signature
|
||||
|
||||
executed = []
|
||||
monkeypatch.setitem(
|
||||
plugins.REGISTRY,
|
||||
"partnership",
|
||||
lambda proposal, cfg, sess: executed.append(proposal.id) or {},
|
||||
)
|
||||
assert executor.run(cfg(), sess) == 0
|
||||
assert executed == []
|
||||
assert p.status is ProposalStatus.PROPOSED
|
||||
|
||||
|
||||
def test_failing_plugin_retries_then_fails(cfg, sess, signer, monkeypatch):
|
||||
p = _proposal(kind="affiliate", title="Will fail")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def boom(proposal, cfg, sess):
|
||||
calls["n"] += 1
|
||||
raise RuntimeError("upstream exploded")
|
||||
|
||||
monkeypatch.setitem(plugins.REGISTRY, "affiliate", boom)
|
||||
config = cfg(CAYCE_OPS_MAX_ATTEMPTS="2")
|
||||
|
||||
executor.run(config, sess) # attempt 1 -> back to approved for retry
|
||||
assert p.status is ProposalStatus.APPROVED
|
||||
assert p.attempts == 1
|
||||
|
||||
executor.run(config, sess) # attempt 2 -> failed for good
|
||||
assert p.status is ProposalStatus.FAILED
|
||||
assert p.attempts == 2
|
||||
assert calls["n"] == 2
|
||||
|
||||
errors = sess.query(AuditEntry).filter_by(result="error").all()
|
||||
assert any(a.action == "fail" for a in errors)
|
||||
|
||||
|
||||
def test_http_post_blocked_when_disabled(cfg, sess):
|
||||
p = _proposal(kind="affiliate", title=" webhook target")
|
||||
p.detail = {"webhook_url": "https://hooks.example.org/ingest"}
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
config = cfg() # CAYCE_OPS_ENABLE_HTTP_ACTIONS=false
|
||||
with pytest.raises(PermissionError):
|
||||
plugins.http_post(p, config, sess)
|
||||
|
||||
|
||||
def test_unknown_kind_falls_back_to_log_action(cfg, sess, signer):
|
||||
p = _proposal(kind="brand_new_kind", title="No plugin yet")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
assert executor.run(cfg(), sess) == 0
|
||||
assert p.status is ProposalStatus.DONE
|
||||
|
||||
done = sess.query(AuditEntry).filter_by(action="execute_done").one()
|
||||
assert done.detail["outcome"]["plugin_fallback"] is True
|
||||
@@ -0,0 +1,530 @@
|
||||
"""maild tests — parsing, classification, threading, gated send. Invented data only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from coincurve import PrivateKey
|
||||
from coincurve import PublicKey as _PK
|
||||
|
||||
from cayceops.components.maild import classify as clf
|
||||
from cayceops.components.maild import maild
|
||||
from cayceops.models import (
|
||||
AuditEntry,
|
||||
DraftStatus,
|
||||
MailClassification,
|
||||
MailDirection,
|
||||
MailMessage,
|
||||
Proposal,
|
||||
ProposalStatus,
|
||||
)
|
||||
|
||||
# test operator keypair (matches conftest default pubkey; invented)
|
||||
OPERATOR_SECRET = bytes.fromhex("22" * 32)
|
||||
OPERATOR_PUBKEY = _PK.from_secret(OPERATOR_SECRET).format().hex()[2:]
|
||||
|
||||
OUTBOUND_MSGID = "<cayce-draft-1-1000@cayce-co.xyz>"
|
||||
|
||||
# the signing gate reads env at runtime — make sure every path in this module
|
||||
# (fixtures included) sees a signed deployment
|
||||
os.environ.setdefault("CAYCE_OPS_DEPLOYMENT_ID", "itest")
|
||||
os.environ.setdefault("CAYCE_OPS_OPERATOR_PUBKEY", OPERATOR_PUBKEY)
|
||||
from cayceops.config import load as _load # noqa: E402 (env set above)
|
||||
|
||||
STUB_CFG = _load() # a real, signed Config (replaces the old SimpleNamespace)
|
||||
|
||||
|
||||
|
||||
def _eml(subject, body, in_reply_to=None, msgid=None) -> bytes:
|
||||
lines = [
|
||||
"From: Partner Desk <partners@acme.example.org>",
|
||||
"To: agent@cayce-co.xyz",
|
||||
f"Subject: {subject}",
|
||||
f"Message-ID: {msgid or '<acme-reply-1@acme.example.org>'}",
|
||||
]
|
||||
if in_reply_to:
|
||||
lines.append(f"In-Reply-To: {in_reply_to}")
|
||||
lines += ["MIME-Version: 1.0", "Content-Type: text/plain; charset=utf-8", "", body]
|
||||
return "\r\n".join(lines).encode()
|
||||
|
||||
|
||||
def _sign(msg: bytes, secret: bytes = OPERATOR_SECRET) -> str:
|
||||
|
||||
return PrivateKey(secret).sign_schnorr(msg, aux_randomness=b"\x00" * 32).hex()
|
||||
|
||||
|
||||
def _approve_signed(sess, mail_id: int) -> str:
|
||||
"""Approve with a valid operator signature over the row's current content."""
|
||||
from cayceops.signing import approval_message, mail_content_hash
|
||||
|
||||
record = sess.get(MailMessage, mail_id)
|
||||
to_addr = record.from_addr if record.direction == MailDirection.INBOUND else record.to_addr
|
||||
chash = mail_content_hash(to_addr, record.subject, record.draft_reply)
|
||||
return _sign(approval_message("mail", "itest", mail_id, chash))
|
||||
|
||||
|
||||
def _proposal_with_sent_draft(sess) -> Proposal:
|
||||
"""A proposal whose first-contact mail went through the REAL path:
|
||||
compose -> approve (signed) -> send (creating a true OUTBOUND row)."""
|
||||
p = Proposal(
|
||||
kind="partnership",
|
||||
title="Acme hotel affiliate program",
|
||||
status=ProposalStatus.APPROVED,
|
||||
)
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
mail_id = maild._compose(
|
||||
p.id, "partners@acme.example.org", "[cayce] partnership inquiry", "Hello!", sess
|
||||
)
|
||||
assert (
|
||||
maild._approve_draft(mail_id, "gustavo", sess, signature=_approve_signed(sess, mail_id))
|
||||
== 0
|
||||
)
|
||||
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
|
||||
out = (
|
||||
sess.query(MailMessage)
|
||||
.filter_by(direction=MailDirection.OUTBOUND, proposal_id=p.id)
|
||||
.one()
|
||||
)
|
||||
out.message_id = OUTBOUND_MSGID # deterministic id for the fixtures
|
||||
sess.flush()
|
||||
return p
|
||||
|
||||
|
||||
def _fetcher(raws: list[bytes]):
|
||||
return lambda cfg: [(bytes(str(i), "ascii"), raw) for i, raw in enumerate(raws)]
|
||||
|
||||
|
||||
# --- classifier ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rules_classification(cfg):
|
||||
config = cfg()
|
||||
assert (
|
||||
clf.classify("Your application was approved, welcome to the program", config)[0]
|
||||
is MailClassification.APPROVAL
|
||||
)
|
||||
# negation present -> approval is vetoed to needs_review (round-2 rule;
|
||||
# the human decides between rejection and other)
|
||||
assert (
|
||||
clf.classify("Unfortunately we cannot accept new partners", config)[0]
|
||||
is MailClassification.NEEDS_REVIEW
|
||||
)
|
||||
# plain rejection (no approval word involved) still classifies directly
|
||||
assert (
|
||||
clf.classify("We regret to inform you that your application was declined", config)[0]
|
||||
is MailClassification.REJECTION
|
||||
)
|
||||
assert (
|
||||
clf.classify("Could you provide your traffic numbers?", config)[0]
|
||||
is MailClassification.QUESTION
|
||||
)
|
||||
kind, _ = clf.classify("grocery list", config)
|
||||
assert kind is MailClassification.OTHER or kind is MailClassification.NEEDS_REVIEW
|
||||
|
||||
|
||||
def test_negated_approvals_never_classify_approval(cfg):
|
||||
"""Round-2 point 2: any negation vetoes `approval` -> needs_review."""
|
||||
config = cfg()
|
||||
cases = [
|
||||
"We cannot currently accept new affiliates",
|
||||
"We will never accept crypto businesses",
|
||||
"Approval was not granted for your site",
|
||||
"We are no longer accepting applications",
|
||||
"Your application hasn't been approved",
|
||||
"We have decided not to move forward with your application",
|
||||
"We cannot accept your application",
|
||||
"We are not accepting new partners at this time",
|
||||
"Your application was not approved",
|
||||
"We can't approve this request",
|
||||
"We do not accept external affiliates",
|
||||
]
|
||||
for text in cases:
|
||||
kind, _ = clf.classify(text, config)
|
||||
assert kind is not MailClassification.APPROVAL, f"{text!r} -> {kind}"
|
||||
|
||||
|
||||
def test_llm_prompt_format_survives_json_braces():
|
||||
"""Critical finding 4: .format() raised KeyError on the JSON sample."""
|
||||
formatted = clf._LLM_PROMPT.format(body="x")
|
||||
assert '{"classification"' in formatted # literal braces survive
|
||||
|
||||
|
||||
def test_low_confidence_becomes_needs_review(cfg):
|
||||
config = cfg(CAYCE_OPS_LLM_REVIEW_THRESHOLD="0.95")
|
||||
kind, confidence = clf.classify("Your application was approved, welcome", config)
|
||||
assert kind is MailClassification.NEEDS_REVIEW
|
||||
assert confidence < 0.95
|
||||
|
||||
|
||||
# --- inbound pass ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_poll_threads_approval_and_drafts_reply(cfg, sess):
|
||||
p = _proposal_with_sent_draft(sess)
|
||||
raw = _eml(
|
||||
"Re: [cayce] partnership inquiry",
|
||||
"Approved! Welcome to the Acme partner program.",
|
||||
in_reply_to=OUTBOUND_MSGID,
|
||||
)
|
||||
|
||||
stats, nums = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
assert stats["processed"] == 1 and stats["drafted"] == 1
|
||||
assert len(nums) == 1
|
||||
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert inbound.classification is MailClassification.APPROVAL
|
||||
assert inbound.proposal_id == p.id
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||
assert inbound.draft_reply and "approval" in inbound.draft_reply.lower()
|
||||
|
||||
assert p.detail["merchant_status"] == "approval"
|
||||
assert p.status is ProposalStatus.APPROVED # lifecycle untouched by maild
|
||||
|
||||
|
||||
def test_poll_idempotent_on_message_id(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: hi", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
|
||||
first, second = maild._poll(cfg(), sess, fetcher=_fetcher([raw])), maild._poll(
|
||||
cfg(), sess, fetcher=_fetcher([raw])
|
||||
)
|
||||
assert first[0]["processed"] == 1
|
||||
assert second[0]["skipped"] == 1
|
||||
|
||||
|
||||
def test_unthreaded_approval_needs_review(cfg, sess):
|
||||
raw = _eml("Great news", "Approved! Welcome aboard.") # no In-Reply-To
|
||||
stats, _ = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert inbound.classification is MailClassification.NEEDS_REVIEW
|
||||
assert stats["needs_review"] == 1
|
||||
assert inbound.draft_status is DraftStatus.NONE # no draft for review items
|
||||
|
||||
|
||||
def test_question_gets_clarification_draft(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Could you provide your monthly traffic?", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert inbound.classification is MailClassification.QUESTION
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||
assert "traffic" in inbound.draft_reply
|
||||
|
||||
|
||||
# --- human gate -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_send_blocked_without_approval(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
|
||||
sent = []
|
||||
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
|
||||
assert stats["sent"] == 0 and sent == []
|
||||
assert stats["blocked"] + stats["missing"] >= 0 # no crash paths
|
||||
assert config.mail_from == "agent@cayce-co.xyz"
|
||||
row = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert row.draft_status is DraftStatus.DRAFTED
|
||||
|
||||
|
||||
def test_send_requires_explicit_human_approval(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml(
|
||||
"Re: inquiry",
|
||||
"Approved, welcome to the program.",
|
||||
in_reply_to=OUTBOUND_MSGID,
|
||||
)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
original_id = inbound.message_id
|
||||
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
sent = []
|
||||
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||
|
||||
def sender(cfg, to, subject, body, message_id, in_reply_to=None):
|
||||
sent.append((to, subject, body, message_id, in_reply_to))
|
||||
|
||||
stats = maild._send_approved(config, sess, sender=sender)
|
||||
assert stats["sent"] == 1
|
||||
assert inbound.draft_status is DraftStatus.SENT
|
||||
to, subject, body, message_id, in_reply_to = sent[0]
|
||||
assert to == "partners@acme.example.org"
|
||||
assert in_reply_to == original_id # In-Reply-To threads to the inbound mail
|
||||
|
||||
# a real OUTBOUND row now exists (audit fix: they were never created)
|
||||
out = (
|
||||
sess.query(MailMessage)
|
||||
.filter_by(direction=MailDirection.OUTBOUND, message_id=message_id)
|
||||
.one()
|
||||
)
|
||||
assert out.in_reply_to == original_id
|
||||
assert out.proposal_id == inbound.proposal_id
|
||||
assert inbound.message_id == original_id # inbound id NOT overwritten
|
||||
|
||||
row = (
|
||||
sess.query(AuditEntry)
|
||||
.filter_by(action="send_draft")
|
||||
.order_by(AuditEntry.id.desc())
|
||||
.first()
|
||||
)
|
||||
assert row is not None and "gustavo" in str(row.detail)
|
||||
|
||||
|
||||
def test_double_approve_is_rejected(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert maild._approve_draft(inbound.id, "gustavo", sess) == 1
|
||||
|
||||
|
||||
# --- audit corrections ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_send_failure_does_not_resurrect_sent_mails(cfg, sess):
|
||||
"""Critical finding 3: SMTP failure mid-batch rolled back the whole
|
||||
transaction, re-sending already-delivered mails 5 minutes later."""
|
||||
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||
for i in range(3):
|
||||
# unique first-contact thread per proposal (real compose -> send path)
|
||||
p = Proposal(kind="partnership", title=f"Acme partner {i}")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
mail_id = maild._compose(
|
||||
p.id, f"desk{i}@acme.example.org", f"[cayce] inquiry {i}", "Hello!", sess
|
||||
)
|
||||
assert (
|
||||
maild._approve_draft(mail_id, "gustavo", sess, signature=_approve_signed(sess, mail_id))
|
||||
== 0
|
||||
)
|
||||
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
|
||||
sess.flush()
|
||||
out = sess.get(MailMessage, mail_id)
|
||||
raw = _eml(
|
||||
f"Re: {i}",
|
||||
"Approved, welcome to the program.",
|
||||
in_reply_to=out.message_id,
|
||||
msgid=f"<acme-{i}@acme.example.org>",
|
||||
)
|
||||
maild._poll(config, sess, fetcher=_fetcher([raw]))
|
||||
inbound = (
|
||||
sess.query(MailMessage)
|
||||
.filter_by(direction=MailDirection.INBOUND, message_id=f"<acme-{i}@acme.example.org>")
|
||||
.one()
|
||||
)
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
sess.commit() # per-message factory sessions only see committed rows
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_smtp(cfg, to, subject, body, message_id, in_reply_to=None):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 3:
|
||||
raise OSError("smtp connection lost")
|
||||
|
||||
import cayceops.db as appdb
|
||||
|
||||
stats = maild._send_approved(
|
||||
config, sess, sender=flaky_smtp, factory=appdb.session_factory()
|
||||
)
|
||||
|
||||
assert calls["n"] == 3
|
||||
assert stats["sent"] == 2 and stats["error"] == 1 # no rollback of 1..2
|
||||
still_approved = (
|
||||
sess.query(MailMessage)
|
||||
.filter_by(direction=MailDirection.INBOUND, draft_status=DraftStatus.APPROVED)
|
||||
.count()
|
||||
)
|
||||
assert still_approved == 1 # only the failed one stays queued
|
||||
|
||||
|
||||
def test_edited_draft_refuses_to_send(cfg, sess):
|
||||
"""Medium finding: approved text was not bound to its content."""
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
inbound.draft_reply = "TOTALLY EDITED TEXT after approval" # tamper
|
||||
|
||||
sent = []
|
||||
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
|
||||
assert stats["sent"] == 0 and stats["hash_mismatch"] == 1
|
||||
assert sent == []
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED # needs fresh approval
|
||||
action = sess.query(AuditEntry).filter_by(action="send_refused").one()
|
||||
assert action.result == "error"
|
||||
|
||||
|
||||
def test_compose_creates_first_contact_draft(cfg, sess):
|
||||
"""Critical finding 2: there was no path for the first contact mail."""
|
||||
p = Proposal(kind="partnership", title="New hotel partner")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
mail_id = maild._compose(
|
||||
p.id, "desk@hotel.example.org", "Partnership", "We would like to...", sess
|
||||
)
|
||||
assert mail_id > 0
|
||||
out = sess.get(MailMessage, mail_id)
|
||||
assert out.direction is MailDirection.OUTBOUND
|
||||
assert out.draft_status is DraftStatus.DRAFTED
|
||||
assert out.proposal_id == p.id
|
||||
|
||||
# approve + send -> an inbound reply can now thread to this proposal
|
||||
assert (
|
||||
maild._approve_draft(mail_id, "gustavo", sess, signature=_approve_signed(sess, mail_id))
|
||||
== 0
|
||||
)
|
||||
stats = maild._send_approved(STUB_CFG, sess, sender=lambda *a, **k: None)
|
||||
assert stats["sent"] == 1
|
||||
sess.flush()
|
||||
|
||||
reply = _eml("Re: Partnership", "Approved, welcome!", in_reply_to=out.message_id)
|
||||
assert maild._match_proposal(maild.parse_raw(reply), sess) is p
|
||||
|
||||
|
||||
def test_mark_seen_returned_for_post_commit_processing(cfg, sess):
|
||||
"""Medium finding: \\\\Seen was set before processing (crash lost mail)."""
|
||||
raw = _eml("Re: hi", "Approved, welcome to the program.")
|
||||
stats, nums = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
assert stats["processed"] == 1
|
||||
assert nums == [b"0"] # caller marks seen only after commit
|
||||
|
||||
|
||||
# --- round-2 signature gate (mail) ------------------------------------------------
|
||||
|
||||
|
||||
def _approved_reply(sess, cfg) -> MailMessage:
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
return sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
|
||||
|
||||
def test_unsigned_sql_approved_draft_is_refused(cfg, sess):
|
||||
"""Round-2 required test: raw-SQL approved row (no signature) not sent."""
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
# raw-SQL style approval: status flips, no signature
|
||||
sess.execute(
|
||||
MailMessage.__table__.update()
|
||||
.where(MailMessage.id == inbound.id)
|
||||
.values(draft_status=DraftStatus.APPROVED)
|
||||
)
|
||||
sess.expire(inbound)
|
||||
|
||||
sent = []
|
||||
stats = maild._send_approved(STUB_CFG, sess, sender=lambda *a, **k: sent.append(a))
|
||||
assert stats["sent"] == 0 and stats["hash_mismatch"] == 1
|
||||
assert sent == []
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED # reverted
|
||||
refused = sess.query(AuditEntry).filter_by(action="send_refused").one()
|
||||
assert refused.result == "error"
|
||||
|
||||
|
||||
def test_changed_to_addr_after_approval_blocks_send(cfg, sess):
|
||||
"""Round-2 required test: to_addr edited after approval -> refused."""
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
inbound.from_addr = "attacker@evil.example.org" # redirect target
|
||||
|
||||
sent = []
|
||||
stats = maild._send_approved(STUB_CFG, sess, sender=lambda *a, **k: sent.append(a))
|
||||
assert stats["sent"] == 0 and stats["hash_mismatch"] == 1
|
||||
assert sent == []
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||
|
||||
|
||||
def test_foreign_deployment_signature_refused(cfg, sess):
|
||||
"""Round-2 required test: a valid signature for another deployment_id (or
|
||||
other content) is refused."""
|
||||
from cayceops.signing import approval_message, mail_content_hash
|
||||
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
chash = mail_content_hash(inbound.from_addr, inbound.subject, inbound.draft_reply)
|
||||
|
||||
# signature valid for ANOTHER deployment
|
||||
other_dep = _sign(
|
||||
OPERATOR_SECRET, approval_message("mail", "other-deploy", inbound.id, chash)
|
||||
)
|
||||
assert maild._approve_draft(inbound.id, "gustavo", sess, signature=other_dep) == 2
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||
|
||||
# signature valid for OTHER CONTENT
|
||||
other_content = _sign(
|
||||
OPERATOR_SECRET, approval_message("mail", "itest", inbound.id, "00" * 32)
|
||||
)
|
||||
assert maild._approve_draft(inbound.id, "gustavo", sess, signature=other_content) == 2
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||
|
||||
|
||||
def test_send_failures_audited_and_exit_code(cfg, sess, monkeypatch):
|
||||
"""Round-2 point 5: failures audited + surfaced; non-zero exit handled by CLI."""
|
||||
import cayceops.db as appdb
|
||||
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
def boom(cfg, to, subject, body, message_id, in_reply_to=None):
|
||||
raise OSError("relay down")
|
||||
|
||||
|
||||
sess.commit() # per-message factory sessions only see committed rows
|
||||
stats = maild._send_approved(
|
||||
STUB_CFG, sess, sender=boom, factory=appdb.session_factory()
|
||||
)
|
||||
assert stats["error"] == 1 and stats["sent"] == 0
|
||||
failed = sess.query(AuditEntry).filter_by(action="send_failed").one()
|
||||
assert failed.result == "error"
|
||||
@@ -90,3 +90,55 @@ def test_mail_and_content_and_runs(sess):
|
||||
assert m.id and c.id and r.id
|
||||
assert m.draft_status == "drafted"
|
||||
assert c.status == "draft"
|
||||
|
||||
|
||||
def test_audit_chain_and_verification(sess):
|
||||
from cayceops.models import verify_audit_chain
|
||||
|
||||
p = Proposal(kind="other", title="chain")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
for i in range(3):
|
||||
audit(sess, actor=f"actor-{i}", action="step", proposal_id=p.id, detail={"i": i})
|
||||
sess.flush()
|
||||
|
||||
ok, broken = verify_audit_chain(sess)
|
||||
assert ok and broken is None
|
||||
|
||||
# tamper with one entry -> chain breaks at that id
|
||||
first = sess.query(AuditEntry).order_by(AuditEntry.id).first()
|
||||
first.actor = "tampered"
|
||||
ok, broken = verify_audit_chain(sess)
|
||||
assert not ok and broken == first.id
|
||||
|
||||
|
||||
def test_concurrent_writers_keep_chain_verifiable(db_engine):
|
||||
"""Round-2 point 4: serialized writes -> no forked chains."""
|
||||
import threading
|
||||
|
||||
import cayceops.db as appdb
|
||||
|
||||
errors: list[Exception] = []
|
||||
|
||||
def writer(worker: int, n: int):
|
||||
try:
|
||||
for i in range(n):
|
||||
with appdb.session_scope() as s:
|
||||
audit(s, actor=f"w{worker}", action=f"step{i}", detail={"worker": worker})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(w, 25)) for w in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, errors
|
||||
s = appdb.session_factory()()
|
||||
total = s.query(AuditEntry).count()
|
||||
assert total == 100
|
||||
from cayceops.models import verify_audit_chain
|
||||
|
||||
ok, broken = verify_audit_chain(s)
|
||||
assert ok and broken is None
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""monitor tests — page renders, no sensitive data, escapes input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cayceops.components.monitor import monitor
|
||||
from cayceops.models import ContentItem, DaemonRun, MailMessage, Proposal
|
||||
|
||||
|
||||
def _seed(sess):
|
||||
sess.add(DaemonRun(daemon="scanner", status="ok", detail={"proposed": 3}))
|
||||
sess.add(Proposal(kind="affiliate", title="Title with <script>alert(1)</script>"))
|
||||
sess.add(
|
||||
MailMessage(
|
||||
direction="in",
|
||||
from_addr="secret-person@private.example.org",
|
||||
subject="private subject",
|
||||
classification="approval",
|
||||
draft_status="drafted",
|
||||
)
|
||||
)
|
||||
sess.add(ContentItem(theme="atm-fees", channel="nostr", caption="caption", status="draft"))
|
||||
sess.flush()
|
||||
|
||||
|
||||
def test_build_renders_all_sections(cfg, sess, tmp_path):
|
||||
_seed(sess)
|
||||
out = tmp_path / "status.html"
|
||||
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
|
||||
page = out.read_text()
|
||||
|
||||
assert "daemon health" in page
|
||||
assert "proposal queue" in page
|
||||
assert "recent audit activity" in page
|
||||
assert "scanner" in page
|
||||
assert "proposed: <strong>1</strong>" in page
|
||||
assert "mail drafts awaiting approval: <strong>1</strong>" in page
|
||||
|
||||
|
||||
def test_build_leaks_no_sensitive_data(cfg, sess, tmp_path):
|
||||
_seed(sess)
|
||||
out = tmp_path / "status.html"
|
||||
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
|
||||
page = out.read_text()
|
||||
|
||||
assert "secret-person@private.example.org" not in page # no addresses
|
||||
assert "private subject" not in page # no subjects
|
||||
assert "caption" not in page # no content bodies
|
||||
assert "Title with" not in page # proposal titles not rendered
|
||||
|
||||
|
||||
def test_build_escapes_injected_content(cfg, sess, tmp_path):
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
sess.add(
|
||||
AuditEntry(
|
||||
ts=datetime.now(UTC),
|
||||
actor="<script>x</script>",
|
||||
action="probe",
|
||||
detail={},
|
||||
)
|
||||
)
|
||||
sess.flush()
|
||||
out = tmp_path / "status.html"
|
||||
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
|
||||
page = out.read_text()
|
||||
assert "<script>x</script>" not in page
|
||||
assert "<script>x</script>" in page
|
||||
|
||||
|
||||
def test_anchor_signs_and_logs_once_per_day(cfg, sess, monkeypatch):
|
||||
from cayceops.components.monitor import monitor
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
# seed the chain
|
||||
from cayceops.models import audit as audit_fn
|
||||
|
||||
audit_fn(sess, actor="daemon:executor", action="execute_done", detail={})
|
||||
sess.flush()
|
||||
|
||||
published = []
|
||||
|
||||
def fake_publish(relays, event, timeout=10.0):
|
||||
published.append(event)
|
||||
return [{"relay": r, "ok": "true"} for r in relays]
|
||||
|
||||
import cayceops.components.publisher.nostr as nostr_mod
|
||||
|
||||
monkeypatch.setattr(nostr_mod, "publish", fake_publish)
|
||||
|
||||
config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY="11" * 32,
|
||||
CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org",
|
||||
CAYCE_OPS_DEPLOYMENT_ID="itest",
|
||||
)
|
||||
code = monitor._anchor(config, sess)
|
||||
assert code == 0
|
||||
assert len(published) == 1
|
||||
content = published[0]["content"]
|
||||
assert "audit anchor" in content and "itest" in content
|
||||
entry = sess.query(AuditEntry).filter_by(action="anchor").one()
|
||||
assert entry.detail["last_hash"]
|
||||
|
||||
# second call same day -> skipped, no new nostr event
|
||||
code2 = monitor._anchor(config, sess)
|
||||
assert code2 == 0
|
||||
assert len(published) == 1
|
||||
@@ -0,0 +1,263 @@
|
||||
"""publisher tests — calendar, human gate (signed), nostr publish, gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from cayceops.components.publisher import adapters, nostr, publisher
|
||||
from cayceops.models import AuditEntry, ContentItem, ContentStatus
|
||||
|
||||
# invented test keys (never real — allowlisted in .gitleaks.toml)
|
||||
TEST_KEY = "11" * 32 # nostr signing key
|
||||
OPERATOR_SECRET = bytes.fromhex("22" * 32) # approval signing key
|
||||
|
||||
|
||||
def _sign(msg: bytes, secret: bytes = OPERATOR_SECRET) -> str:
|
||||
from coincurve import PrivateKey
|
||||
|
||||
return PrivateKey(secret).sign_schnorr(msg, aux_randomness=b"\x00" * 32).hex()
|
||||
|
||||
|
||||
def _sign_content(item: ContentItem, deployment: str = "itest") -> str:
|
||||
from cayceops.signing import approval_message, content_content_hash
|
||||
|
||||
chash = content_content_hash(item.channel, item.caption, item.link)
|
||||
return _sign(approval_message("content", deployment, item.id, chash))
|
||||
|
||||
|
||||
def _item(channel="nostr", theme="atm-fees", scheduled=None, status=ContentStatus.APPROVED):
|
||||
return ContentItem(
|
||||
theme=theme,
|
||||
channel=channel,
|
||||
caption="The first ATM you find charges 10-15% over spot.\n\nbitcoinhotels.org/guides/atm",
|
||||
link="bitcoinhotels.org/guides/atm",
|
||||
status=status,
|
||||
scheduled_for=scheduled,
|
||||
)
|
||||
|
||||
|
||||
def _approved_item(cfg, sess, **kw) -> ContentItem:
|
||||
"""A content item properly approved with a signature (env set first)."""
|
||||
cfg() # sets the signing env before any approval
|
||||
item = _item(status=ContentStatus.DRAFT, **kw)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
assert publisher._approve(item.id, "gustavo", sess, signature=_sign_content(item)) == 0
|
||||
return item
|
||||
|
||||
|
||||
# --- nostr client -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_event_build_verify_roundtrip():
|
||||
event = nostr.build_event(
|
||||
bytes.fromhex(TEST_KEY), "gm", tags=[["t", "bitcoin"]], created_at=1700000000
|
||||
)
|
||||
assert nostr.verify_event(event)
|
||||
assert len(event["id"]) == 64 and len(event["sig"]) == 128
|
||||
|
||||
tampered = {**event, "content": "gm (edited)"}
|
||||
assert not nostr.verify_event(tampered)
|
||||
|
||||
|
||||
def test_event_is_deterministic():
|
||||
a = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
|
||||
b = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_key_validation():
|
||||
with pytest.raises(ValueError):
|
||||
nostr.normalize_key("not-a-key")
|
||||
assert nostr.normalize_key("0x" + TEST_KEY) == bytes.fromhex(TEST_KEY)
|
||||
|
||||
|
||||
# --- calendar ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_plan_creates_idempotent_drafts(cfg, sess):
|
||||
config = cfg()
|
||||
first = publisher._plan(config, sess)
|
||||
second = publisher._plan(config, sess)
|
||||
assert first["created"] == 4
|
||||
assert second["created"] == 0 # same themes+channels already queued
|
||||
|
||||
|
||||
def test_approve_gate(cfg, sess):
|
||||
item = _item(status=ContentStatus.DRAFT)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
cfg() # signing env first
|
||||
|
||||
# unsigned approval refused (mandatory signature, no degraded mode)
|
||||
assert publisher._approve(item.id, "gustavo", sess, signature=None) == 2
|
||||
# signed approval accepted, FULL signature stored
|
||||
sig = _sign_content(item)
|
||||
assert publisher._approve(item.id, "gustavo", sess, signature=sig) == 0
|
||||
assert item.status is ContentStatus.APPROVED
|
||||
assert item.operator_signature == sig and len(sig) == 128
|
||||
assert publisher._approve(item.id, "gustavo", sess, signature=sig) == 1 # not draft anymore
|
||||
|
||||
actor = sess.query(AuditEntry).filter_by(action="approve_content").first().actor
|
||||
assert actor == "gustavo"
|
||||
|
||||
|
||||
# --- publishing ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_publish_due_nostr_happy_path(cfg, sess):
|
||||
item = _approved_item(cfg, sess)
|
||||
|
||||
relays_seen = []
|
||||
|
||||
def fake_sender(relays, event, timeout=10.0):
|
||||
relays_seen.append(relays)
|
||||
assert nostr.verify_event(event)
|
||||
return [{"relay": r, "ok": "true"} for r in relays]
|
||||
|
||||
config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
|
||||
CAYCE_OPS_NOSTR_RELAYS="wss://relay.example.org,wss://other.example.org",
|
||||
)
|
||||
stats = publisher._publish_due(config, sess, nostr_sender=fake_sender)
|
||||
|
||||
assert stats["published"] == 1
|
||||
assert item.status is ContentStatus.PUBLISHED
|
||||
assert item.post_ref and len(item.post_ref) == 64
|
||||
assert item.published_at is not None
|
||||
assert "wss://relay.example.org" in relays_seen[0]
|
||||
|
||||
|
||||
def test_future_scheduled_item_not_published(cfg, sess):
|
||||
item = _approved_item(
|
||||
cfg, sess, scheduled=datetime.now(UTC) + timedelta(hours=2)
|
||||
)
|
||||
config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
|
||||
)
|
||||
stats = publisher._publish_due(
|
||||
config, sess, nostr_sender=lambda r, e, timeout=10: []
|
||||
)
|
||||
assert stats["published"] == 0
|
||||
assert item.status is ContentStatus.APPROVED
|
||||
|
||||
|
||||
def test_unsigned_sql_approved_item_not_published(cfg, sess):
|
||||
"""Round-2 required test: raw-SQL approved row (no signature) not published."""
|
||||
config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
|
||||
)
|
||||
item = _item() # status=APPROVED, no signature
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
stats = publisher._publish_due(config, sess, nostr_sender=lambda r, e, timeout=10: [])
|
||||
assert stats["published"] == 0 and stats["tampered"] == 1
|
||||
assert item.status is ContentStatus.DRAFT # reverted
|
||||
refused = sess.query(AuditEntry).filter_by(action="publish_refused").one()
|
||||
assert refused.result == "error"
|
||||
|
||||
|
||||
def test_tampered_caption_refused_by_signature(cfg, sess):
|
||||
"""Round-2 required test: signature over other content refused at publish."""
|
||||
item = _approved_item(cfg, sess)
|
||||
|
||||
item.caption = item.caption + " EDITED" # content no longer matches sig
|
||||
nostr_config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
|
||||
)
|
||||
stats = publisher._publish_due(
|
||||
nostr_config, sess, nostr_sender=lambda r, e, timeout=10: []
|
||||
)
|
||||
assert stats["published"] == 0 and stats["tampered"] == 1
|
||||
assert item.status is ContentStatus.DRAFT
|
||||
|
||||
|
||||
def test_foreign_deployment_signature_refused(cfg, sess, signer):
|
||||
"""Round-2 required test: a valid signature for another deployment_id is
|
||||
refused at approval time."""
|
||||
item = _item(status=ContentStatus.DRAFT)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
other_dep = signer.raw(
|
||||
signer.msg("content", "other-deploy", item.id, signer.content_hash(item))
|
||||
)
|
||||
assert publisher._approve(item.id, "gustavo", sess, signature=other_dep) == 2
|
||||
assert item.status is ContentStatus.DRAFT
|
||||
|
||||
|
||||
def test_nostr_without_config_fails_clean(cfg, sess):
|
||||
"""Signed item but no nostr credentials -> clean failure, item stays."""
|
||||
item = _approved_item(cfg, sess)
|
||||
config = cfg() # no key, no relays
|
||||
stats = publisher._publish_due(config, sess)
|
||||
assert stats["failed"] == 1
|
||||
assert item.status is ContentStatus.APPROVED # stays approved, error recorded
|
||||
assert item.error and "nostr" in item.error
|
||||
|
||||
|
||||
def test_non_nostr_channels_skipped_terminal(cfg, sess):
|
||||
item = _approved_item(cfg, sess, channel="facebook", theme="lightning-abroad")
|
||||
stats = publisher._publish_due(cfg(), sess)
|
||||
assert stats["skipped"] == 1
|
||||
assert item.status is ContentStatus.SKIPPED # terminal (audit fix)
|
||||
|
||||
stats2 = publisher._publish_due(cfg(), sess)
|
||||
assert stats2["skipped"] == 0 # never revisited
|
||||
skips = sess.query(AuditEntry).filter_by(action="publish_skipped_terminal").count()
|
||||
assert skips == 1
|
||||
|
||||
|
||||
def test_meta_adapters_disabled_by_default(cfg):
|
||||
config = cfg()
|
||||
with pytest.raises(PermissionError):
|
||||
adapters.post_facebook(config, "hello")
|
||||
with pytest.raises(PermissionError):
|
||||
adapters.post_instagram(config, "hello", "https://img.example.org/x.jpg")
|
||||
|
||||
|
||||
# --- images ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_brand_image_render(tmp_path, cfg):
|
||||
config = cfg(CAYCE_OPS_MEDIA_DIR=str(tmp_path))
|
||||
out = publisher._render_image(_item(), config)
|
||||
assert os.path.exists(out)
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(out) as img:
|
||||
assert img.size == (1080, 1350)
|
||||
|
||||
|
||||
def test_unknown_brand_rejected():
|
||||
from cayceops.components.publisher import images
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
images.render_brand_image(brand="xx", kicker=None, lines=["A"], out_path="/tmp/x.jpg")
|
||||
|
||||
|
||||
# --- round-2: attempts budget --------------------------------------------------
|
||||
|
||||
|
||||
def test_publish_attempts_bounded(cfg, sess, monkeypatch):
|
||||
"""Medium finding: nostr failures retried without limit."""
|
||||
from cayceops.components.publisher import nostr as nostr_mod
|
||||
|
||||
config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
|
||||
CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org",
|
||||
CAYCE_OPS_MAX_ATTEMPTS="1",
|
||||
)
|
||||
item = _approved_item(cfg, sess)
|
||||
|
||||
def always_fail(relays, event, timeout=10.0):
|
||||
raise RuntimeError("relay down")
|
||||
|
||||
monkeypatch.setattr(nostr_mod, "publish", always_fail)
|
||||
publisher._publish_due(config, sess, nostr_sender=always_fail)
|
||||
assert item.attempts == 1
|
||||
assert item.status is ContentStatus.SKIPPED # budget exhausted -> terminal
|
||||
|
||||
|
||||
import os # noqa: E402 (used by image test)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""scanner tests — discovery, classification, dedupe. No network (injected fetch)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from cayceops.components.scanner import scanner
|
||||
from cayceops.models import Proposal, ProposalStatus
|
||||
|
||||
RSS = b"""<?xml version="1.0"?>
|
||||
<rss><channel>
|
||||
<item><title>Acme launches affiliate program for hotels</title>
|
||||
<link>https://acme.example.org/affiliates</link>
|
||||
<description>Earn commission on every booking.</description></item>
|
||||
<item><title>Weekly crypto digest</title>
|
||||
<link>https://news.example.org/digest</link>
|
||||
<description>Everything unrelated.</description></item>
|
||||
</channel></rss>"""
|
||||
|
||||
PAGE = b"""<html><head><title>Partners</title></head><body>
|
||||
<a href="https://pay.example.org/partner">Become a payment partner</a>
|
||||
<a href="https://pay.example.org/docs">API docs</a>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _fake_fetch(responses):
|
||||
def fetch(url, cfg, timeout=20.0):
|
||||
return responses[url]
|
||||
|
||||
return fetch
|
||||
|
||||
|
||||
SOURCES = [
|
||||
{"type": "rss", "url": "https://feeds.example.org/news.xml"},
|
||||
{"type": "page", "url": "https://pay.example.org/partners"},
|
||||
]
|
||||
|
||||
|
||||
def _cfg(cfg, **kw):
|
||||
return cfg(
|
||||
CAYCE_OPS_SCANNER_SOURCES=json.dumps(SOURCES),
|
||||
CAYCE_OPS_SCANNER_KEYWORDS="affiliate,partner,bitcoin,commission",
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def test_scan_files_proposals_with_kind_and_score(cfg, sess):
|
||||
config = _cfg(cfg)
|
||||
stats = scanner.scan(config, sess, fetch=_fake_fetch({
|
||||
"https://feeds.example.org/news.xml": RSS,
|
||||
"https://pay.example.org/partners": PAGE,
|
||||
}))
|
||||
|
||||
assert stats["proposed"] == 3 # affiliate item + both page links (pages keep all)
|
||||
proposals = sess.query(Proposal).all()
|
||||
by_title = {p.title: p for p in proposals}
|
||||
|
||||
affiliate = by_title["Acme launches affiliate program for hotels"]
|
||||
assert affiliate.kind == "affiliate"
|
||||
assert affiliate.status == ProposalStatus.PROPOSED
|
||||
assert affiliate.match_score > 0
|
||||
assert affiliate.dedupe_key
|
||||
|
||||
partner = by_title["Become a payment partner"]
|
||||
assert partner.kind == "partnership"
|
||||
|
||||
|
||||
def test_scan_is_idempotent_via_dedupe(cfg, sess):
|
||||
config = _cfg(cfg)
|
||||
scanner.scan(config, sess, fetch=_fake_fetch({
|
||||
"https://feeds.example.org/news.xml": RSS,
|
||||
"https://pay.example.org/partners": PAGE,
|
||||
}))
|
||||
stats2 = scanner.scan(config, sess, fetch=_fake_fetch({
|
||||
"https://feeds.example.org/news.xml": RSS,
|
||||
"https://pay.example.org/partners": PAGE,
|
||||
}))
|
||||
assert stats2["proposed"] == 0
|
||||
assert stats2["skipped_existing"] == 3
|
||||
|
||||
|
||||
def test_scan_filters_keywordless_feed_items(cfg, sess):
|
||||
config = _cfg(cfg)
|
||||
scanner.scan(config, sess, fetch=_fake_fetch({
|
||||
"https://feeds.example.org/news.xml": RSS,
|
||||
"https://pay.example.org/partners": PAGE,
|
||||
}))
|
||||
titles = {p.title for p in sess.query(Proposal).all()}
|
||||
assert "Weekly crypto digest" not in titles # no keyword match in feed -> dropped
|
||||
|
||||
|
||||
def test_scan_records_audit_for_each_proposal(cfg, sess):
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
config = _cfg(cfg)
|
||||
scanner.scan(config, sess, fetch=_fake_fetch({
|
||||
"https://feeds.example.org/news.xml": RSS,
|
||||
"https://pay.example.org/partners": PAGE,
|
||||
}))
|
||||
acts = [a.action for a in sess.query(AuditEntry).all()]
|
||||
assert acts.count("propose") == 3
|
||||
assert all(a.actor == "daemon:scanner" for a in sess.query(AuditEntry).all())
|
||||
|
||||
|
||||
def test_scan_survives_source_error(cfg, sess):
|
||||
def broken_fetch(url, cfg, timeout=20.0):
|
||||
raise OSError("connection refused")
|
||||
|
||||
config = _cfg(cfg)
|
||||
stats = scanner.scan(config, sess, fetch=broken_fetch)
|
||||
assert stats["proposed"] == 0
|
||||
assert len(stats["source_errors"]) == 2
|
||||
Reference in New Issue
Block a user