Compare commits
17
Commits
monitor
...
audit-round2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bde785261 | ||
|
|
c855f01da5 | ||
|
|
56b35d3a63 | ||
|
|
ffe425320d | ||
|
|
d77409148c | ||
|
|
903c591a06 | ||
|
|
40453aadb4 | ||
|
|
61d5cfd67d | ||
|
|
c9bd4411e8 | ||
|
|
90e6cfcc19 | ||
|
|
2da5f3ff24 | ||
|
|
2423cd10b8 | ||
|
|
d500d88870 | ||
|
|
a282aedb94 | ||
|
|
abbaf7e4e2 | ||
|
|
6d77b3eff6 | ||
|
|
3f07362b7a |
@@ -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()
|
||||
|
||||
|
||||
@@ -29,12 +29,85 @@ from cayceops.models import AuditEntry, DaemonRun, Proposal, ProposalStatus, aud
|
||||
Actor = str
|
||||
|
||||
|
||||
def _load_cfg():
|
||||
from cayceops.config import load
|
||||
|
||||
return load()
|
||||
|
||||
|
||||
# --- human transitions -------------------------------------------------------
|
||||
|
||||
GATE_ERROR = 2 # missing/invalid configuration or signature
|
||||
|
||||
def approve(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
|
||||
"""Human approves a proposal. Audited with the operator's identity."""
|
||||
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess)
|
||||
|
||||
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:
|
||||
@@ -48,6 +121,7 @@ def _decide(
|
||||
decision: ProposalStatus,
|
||||
cfg: Config | None,
|
||||
sess,
|
||||
signature: str | None = None,
|
||||
) -> int:
|
||||
owned = False
|
||||
if sess is None:
|
||||
@@ -70,11 +144,14 @@ def _decide(
|
||||
)
|
||||
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)
|
||||
audit(sess, actor=actor, action=verb, proposal_id=proposal_id, detail=detail)
|
||||
return 0
|
||||
finally:
|
||||
if owned:
|
||||
@@ -85,9 +162,20 @@ def _decide(
|
||||
|
||||
|
||||
def _auto_approve(cfg: Config, sess) -> int:
|
||||
"""Auto-approve proposed items per policy. Returns count approved."""
|
||||
if not cfg.autonomous_mode or not cfg.autonomous_kinds:
|
||||
return 0
|
||||
"""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))
|
||||
@@ -102,13 +190,15 @@ def _auto_approve(cfg: Config, sess) -> int:
|
||||
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.risk_score.asc(), Proposal.id.asc())
|
||||
.order_by(Proposal.match_score.desc(), Proposal.id.asc())
|
||||
.limit(budget)
|
||||
.all()
|
||||
)
|
||||
@@ -124,7 +214,7 @@ def _auto_approve(cfg: Config, sess) -> int:
|
||||
detail={
|
||||
"reason": "autonomous_mode policy",
|
||||
"kind": proposal.kind,
|
||||
"risk_score": proposal.risk_score,
|
||||
"match_score": proposal.match_score,
|
||||
"daily_cap": cfg.autonomous_daily_cap,
|
||||
},
|
||||
)
|
||||
@@ -135,8 +225,67 @@ def _auto_approve(cfg: Config, sess) -> int:
|
||||
# --- 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(
|
||||
@@ -203,6 +352,13 @@ def run(cfg: Config | None = None, sess=None) -> int:
|
||||
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 = (
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""maild classification: rules first, LLM optional, conservative threshold.
|
||||
"""maild classification: rules + sentence-level negation, LLM optional.
|
||||
|
||||
The LLM never decides alone: any result below ``CAYCE_OPS_LLM_REVIEW_THRESHOLD``
|
||||
confidence becomes ``needs_review``. With the ``null`` provider (default) only
|
||||
deterministic rules run.
|
||||
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
|
||||
@@ -13,16 +17,38 @@ 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, re.compile(r"\b(declin|reject|not able to|unable to|unfortunately|we regret|won.t be able)\w*", re.I), 0.9),
|
||||
(MailClassification.APPROVAL, re.compile(r"\b(approv|accept|welcome to the|you.re (in|approved)|live|activated|glad to)\w*", re.I), 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), 0.7),
|
||||
(
|
||||
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}
|
||||
Reply with strict JSON: {{"classification": "...", "confidence": 0.0-1.0}}
|
||||
|
||||
Subject and body:
|
||||
{body}"""
|
||||
@@ -35,13 +61,20 @@ def _rules(text: str) -> tuple[MailClassification, float]:
|
||||
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.")
|
||||
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"])
|
||||
@@ -63,6 +96,13 @@ def classify(text: str, cfg: Config) -> tuple[MailClassification, float]:
|
||||
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
|
||||
|
||||
@@ -35,9 +35,25 @@ from cayceops.models import (
|
||||
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 ---------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -48,17 +64,33 @@ def connect_imap(cfg: Config) -> imaplib.IMAP4_SSL:
|
||||
return client
|
||||
|
||||
|
||||
def _fetch_unread(cfg: Config) -> list[bytes]:
|
||||
"""Fetch raw unread messages. Returns list of raw RFC822 bytes."""
|
||||
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")
|
||||
raw_messages: list[bytes] = []
|
||||
out: list[tuple[bytes, bytes]] = []
|
||||
for num in data[0].split():
|
||||
_, msg_data = client.fetch(num, "(RFC822)")
|
||||
raw_messages.append(msg_data[0][1])
|
||||
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")
|
||||
return raw_messages
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
@@ -96,7 +128,13 @@ def parse_raw(raw: bytes) -> dict:
|
||||
|
||||
|
||||
def _match_proposal(parsed: dict, sess) -> Proposal | None:
|
||||
"""Thread an inbound mail to its proposal via In-Reply-To of sent drafts."""
|
||||
"""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
|
||||
@@ -108,9 +146,16 @@ def _match_proposal(parsed: dict, sess) -> Proposal | None:
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if sent is None or sent.proposal_id is None:
|
||||
return None
|
||||
return sess.get(Proposal, sent.proposal_id)
|
||||
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 -------------------------------------------------------------------
|
||||
@@ -144,10 +189,15 @@ def _build_draft(parsed: dict, kind: MailClassification, proposal: Proposal | No
|
||||
# --- inbound pass ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _poll(cfg: Config, sess, fetcher=None) -> dict:
|
||||
"""One inbound pass: parse, classify, thread, draft. Returns stats."""
|
||||
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}
|
||||
for raw in (fetcher or _fetch_unread)(cfg):
|
||||
processed_nums: list[bytes] = []
|
||||
for num, raw in (fetcher or _fetch_unread)(cfg):
|
||||
parsed = parse_raw(raw)
|
||||
if (
|
||||
sess.query(MailMessage)
|
||||
@@ -186,6 +236,7 @@ def _poll(cfg: Config, sess, fetcher=None) -> dict:
|
||||
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
|
||||
@@ -210,18 +261,42 @@ def _poll(cfg: Config, sess, fetcher=None) -> dict:
|
||||
detail={"mail_id": record.id},
|
||||
)
|
||||
stats["drafted"] += 1
|
||||
return stats
|
||||
return stats, processed_nums
|
||||
|
||||
|
||||
# --- human gate: approve draft -------------------------------------------------
|
||||
|
||||
|
||||
def _approve_draft(mail_id: int, actor: str, sess) -> int:
|
||||
"""Human approves a drafted reply. Only then may SMTP send it."""
|
||||
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 or record.direction != MailDirection.INBOUND:
|
||||
if record is None:
|
||||
return 1
|
||||
if record.draft_status != DraftStatus.DRAFTED:
|
||||
if record.draft_status != DraftStatus.DRAFTED or not record.draft_reply:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
@@ -231,13 +306,52 @@ def _approve_draft(mail_id: int, actor: str, sess) -> int:
|
||||
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},
|
||||
detail={"mail_id": record.id, "hash": record.approved_hash, "signature": signature},
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -245,12 +359,21 @@ def _approve_draft(mail_id: int, actor: str, sess) -> int:
|
||||
# --- SMTP: only approved drafts ------------------------------------------------
|
||||
|
||||
|
||||
def _smtp_send(cfg: Config, to_addr: str, subject: str, body: str, message_id: str) -> None:
|
||||
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()
|
||||
@@ -267,52 +390,208 @@ def _approved_by(sess, mail_id: int) -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _send_approved(cfg: Config, sess, sender=None) -> dict:
|
||||
"""Send every APPROVED draft. Anything else stays untouched."""
|
||||
stats = {"sent": 0, "blocked": 0}
|
||||
pending = (
|
||||
sess.query(MailMessage)
|
||||
.filter(
|
||||
MailMessage.direction == MailDirection.INBOUND,
|
||||
MailMessage.draft_status == DraftStatus.APPROVED,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for record in pending:
|
||||
to_addr = record.from_addr or ""
|
||||
if not to_addr:
|
||||
record.draft_status = DraftStatus.SKIPPED
|
||||
stats["blocked"] += 1
|
||||
continue
|
||||
message_id = (
|
||||
f"<cayce-draft-{record.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>"
|
||||
)
|
||||
(sender or _smtp_send)(
|
||||
cfg,
|
||||
to_addr,
|
||||
f"Re: {record.subject or 'your message'}",
|
||||
record.draft_reply or "",
|
||||
message_id,
|
||||
)
|
||||
approved_by = _approved_by(sess, record.id)
|
||||
record.draft_status = DraftStatus.SENT
|
||||
# store the Message-ID so future replies thread to this draft
|
||||
record.message_id = message_id
|
||||
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_draft",
|
||||
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,
|
||||
"message_id": message_id,
|
||||
"approved_by": approved_by,
|
||||
"reason": "no valid operator signature for current content/deployment",
|
||||
"content_hash": current_hash,
|
||||
"had_signature": bool(record.operator_signature),
|
||||
},
|
||||
)
|
||||
stats["sent"] += 1
|
||||
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) ------------------------------------
|
||||
|
||||
|
||||
@@ -322,12 +601,13 @@ def poll(cfg: Config | None = None) -> int:
|
||||
|
||||
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 = _poll(cfg, sess)
|
||||
stats, nums = _poll(cfg, sess)
|
||||
except Exception as exc: # noqa: BLE001 — daemon boundary
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"error": str(exc)[:300]}
|
||||
@@ -336,29 +616,66 @@ def poll(cfg: Config | None = None) -> int:
|
||||
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) -> int:
|
||||
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)
|
||||
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
|
||||
from cayceops.db import engine, session_factory
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
factory = session_factory()
|
||||
with session_scope() as sess:
|
||||
_send_approved(cfg, sess)
|
||||
return 0
|
||||
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()
|
||||
|
||||
@@ -29,9 +29,11 @@ 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}
|
||||
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}
|
||||
.card{display:inline-block;background:#141210;border:1px solid #2a2a2a;
|
||||
border-radius:8px;padding:.8rem 1.4rem;margin:.3rem}
|
||||
</style>"""
|
||||
|
||||
|
||||
@@ -49,33 +51,45 @@ def _daemon_rows(sess) -> str:
|
||||
.first()
|
||||
)
|
||||
if last is None:
|
||||
rows.append(f"<tr><td>{name}</td><td class=muted>never run</td><td>—</td></tr>")
|
||||
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(
|
||||
f"<tr><td>{html.escape(name)}</td>"
|
||||
f"<td class={cls}>{html.escape(last.status)}</td>"
|
||||
f"<td>{_fmt_ts(last.finished_at or last.started_at)}</td></tr>"
|
||||
"<tr><td>" + html.escape(name) + "</td>"
|
||||
+ '<td class="' + cls + '">' + html.escape(last.status) + "</td>"
|
||||
+ "<td>" + when + "</td></tr>"
|
||||
)
|
||||
return "<table><tr><th>daemon</th><th>last status</th><th>last run</th></tr>" + "".join(rows) + "</table>"
|
||||
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 = [
|
||||
f'<div class=card>{html.escape(status.value)}: <strong>{counts.get(status, 0)}</strong></div>'
|
||||
for status in ProposalStatus
|
||||
]
|
||||
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 = []
|
||||
for entry in sess.query(AuditEntry).order_by(AuditEntry.ts.desc()).limit(limit).all():
|
||||
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(
|
||||
f"<tr><td>{_fmt_ts(entry.ts)}</td><td>{html.escape(entry.actor)}</td>"
|
||||
f"<td>{html.escape(entry.action)}</td><td class={cls}>{html.escape(entry.result)}</td></tr>"
|
||||
"<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 (
|
||||
@@ -133,6 +147,85 @@ def _build(cfg: Config, sess) -> str:
|
||||
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
|
||||
|
||||
@@ -15,9 +15,19 @@ 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)},
|
||||
"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)},
|
||||
"hc": {
|
||||
"bg": (13, 13, 16),
|
||||
"accent": (247, 147, 26),
|
||||
"text": (250, 250, 250),
|
||||
"muted": (120, 120, 130),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -119,7 +119,14 @@ def _schnorr_verify(sig32: bytes, msg32: bytes, xonly_pub32: bytes) -> bool:
|
||||
if pub_point is None:
|
||||
return False
|
||||
e = int.from_bytes(tag_hash("BIP0340/challenge", sig32[:32] + xonly_pub32 + msg32), "big") % N
|
||||
r_point = point_add(point_mul((Gx := 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, Gy := 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8), s), point_mul(pub_point, (N - e) % 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
|
||||
@@ -134,12 +141,23 @@ def verify_event(event: dict[str, Any]) -> bool:
|
||||
)
|
||||
if hashlib.sha256(serialized.encode("utf-8")).hexdigest() != event["id"]:
|
||||
return False
|
||||
compressed = PublicKey(bytes.fromhex("02" + event["pubkey"])).format() # 33-byte, prefix 02
|
||||
# 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
|
||||
@@ -152,7 +170,9 @@ def relay_publish(relay_url: str, event: dict[str, Any], timeout: float = 10.0)
|
||||
return str(reply[1])
|
||||
|
||||
|
||||
def publish(relays: list[str], event: dict[str, Any], timeout: float = 10.0) -> list[dict[str, str]]:
|
||||
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:
|
||||
|
||||
@@ -16,7 +16,13 @@ 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, audit
|
||||
from cayceops.models import (
|
||||
ContentItem,
|
||||
ContentStatus,
|
||||
DaemonRun,
|
||||
_content_hash,
|
||||
audit,
|
||||
)
|
||||
|
||||
# rotation: one theme per slot; channel rotation nostr -> facebook -> instagram
|
||||
THEMES = [
|
||||
@@ -37,10 +43,22 @@ def _caption(theme: str, link: str) -> str:
|
||||
"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.",
|
||||
"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()),
|
||||
@@ -83,7 +101,12 @@ def _plan(cfg: Config, sess, days: int = 4) -> dict:
|
||||
)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
audit(sess, actor="daemon:publisher", action="plan_draft", detail={"item": item.id, "theme": theme, "channel": channel})
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="plan_draft",
|
||||
detail={"item": item.id, "theme": theme, "channel": channel},
|
||||
)
|
||||
created += 1
|
||||
return {"created": created}
|
||||
|
||||
@@ -91,7 +114,33 @@ def _plan(cfg: Config, sess, days: int = 4) -> dict:
|
||||
# --- human gate -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _approve(item_id: int, actor: str, sess) -> int:
|
||||
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(
|
||||
@@ -102,11 +151,58 @@ def _approve(item_id: int, actor: str, sess) -> int:
|
||||
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
|
||||
audit(sess, actor=actor, action="approve_content", detail={"item": item.id, "channel": item.channel})
|
||||
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 ----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -132,7 +228,9 @@ def _render_image(item: ContentItem, cfg: Config) -> str | None:
|
||||
|
||||
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")
|
||||
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
|
||||
@@ -150,7 +248,7 @@ def _publish_nostr(item: ContentItem, cfg: Config, sess, sender=None) -> str:
|
||||
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}
|
||||
stats = {"published": 0, "skipped": 0, "failed": 0, "tampered": 0}
|
||||
due = (
|
||||
sess.query(ContentItem)
|
||||
.filter(ContentItem.status == ContentStatus.APPROVED)
|
||||
@@ -160,31 +258,83 @@ def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
|
||||
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",
|
||||
detail={
|
||||
"item": item.id,
|
||||
"channel": item.channel,
|
||||
"reason": f"{item.channel} adapter disabled by default",
|
||||
},
|
||||
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]
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_failed",
|
||||
result="error",
|
||||
detail={"item": item.id, "error": item.error},
|
||||
)
|
||||
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
|
||||
@@ -231,6 +381,13 @@ def publish_due(cfg: Config | None = None) -> int:
|
||||
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
|
||||
@@ -256,11 +413,11 @@ def make_image(item_id: int, cfg: Config | None = None) -> int:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def approve(item_id: int, actor: str) -> int:
|
||||
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)
|
||||
return _approve(item_id, actor, sess, signature)
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
@@ -165,7 +165,7 @@ def scan(cfg: Config, sess, fetch=_fetch) -> dict:
|
||||
"summary": desc,
|
||||
"scanned_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
risk_score=score,
|
||||
match_score=score,
|
||||
status=ProposalStatus.PROPOSED,
|
||||
dedupe_key=dedupe,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
+141
-32
@@ -1,4 +1,9 @@
|
||||
"""executor tests — human gate, autonomy bounds, retries, audit chain."""
|
||||
"""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
|
||||
|
||||
@@ -12,16 +17,22 @@ def _proposal(kind="content_gap", title="Test item", **kw):
|
||||
return Proposal(kind=kind, title=title, **kw)
|
||||
|
||||
|
||||
def test_approve_requires_proposed_state(cfg, sess):
|
||||
def test_approve_requires_proposed_state(cfg, sess, signer):
|
||||
p = _proposal()
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
assert executor.approve(p.id, "gustavo", cfg(), sess) == 0
|
||||
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) == 1
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 1
|
||||
)
|
||||
assert p.approved_by == "gustavo"
|
||||
assert p.status == ProposalStatus.APPROVED
|
||||
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):
|
||||
@@ -29,21 +40,26 @@ def test_reject_records_actor(cfg, sess):
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
assert executor.reject(p.id, "gustavo", cfg(), sess) == 0
|
||||
assert p.status == ProposalStatus.REJECTED
|
||||
assert p.status is ProposalStatus.REJECTED
|
||||
|
||||
|
||||
def test_run_executes_only_approved(cfg, sess):
|
||||
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()
|
||||
executor.approve(approved.id, "gustavo", cfg(), sess)
|
||||
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 == ProposalStatus.DONE
|
||||
assert proposed.status == ProposalStatus.PROPOSED # untouched
|
||||
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
|
||||
@@ -56,36 +72,127 @@ def test_autonomy_off_by_default(cfg, sess):
|
||||
sess.flush()
|
||||
config = cfg() # autonomous_mode False even with kinds configured
|
||||
executor._auto_approve(config, sess)
|
||||
assert p.status == ProposalStatus.PROPOSED
|
||||
assert p.status is ProposalStatus.PROPOSED
|
||||
|
||||
|
||||
def test_autonomy_respects_kinds_and_daily_cap(cfg, sess):
|
||||
p1 = _proposal(kind="content_gap", title="auto ok")
|
||||
p2 = _proposal(kind="content_gap", title="auto over cap")
|
||||
p3 = _proposal(kind="affiliate", title="kind not allowed")
|
||||
sess.add_all([p1, p2, p3])
|
||||
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",
|
||||
)
|
||||
approved = executor._auto_approve(config, sess)
|
||||
assert approved == 1
|
||||
assert p1.status == ProposalStatus.APPROVED
|
||||
assert p2.status == ProposalStatus.PROPOSED # cap reached
|
||||
assert p3.status == ProposalStatus.PROPOSED # kind not in policy
|
||||
|
||||
actor = sess.query(AuditEntry).filter_by(action="approve").one().actor
|
||||
assert actor == "auto:policy"
|
||||
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_failing_plugin_retries_then_fails(cfg, sess, monkeypatch):
|
||||
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()
|
||||
executor.approve(p.id, "gustavo", cfg(), sess)
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
@@ -97,11 +204,11 @@ def test_failing_plugin_retries_then_fails(cfg, sess, monkeypatch):
|
||||
config = cfg(CAYCE_OPS_MAX_ATTEMPTS="2")
|
||||
|
||||
executor.run(config, sess) # attempt 1 -> back to approved for retry
|
||||
assert p.status == ProposalStatus.APPROVED
|
||||
assert p.status is ProposalStatus.APPROVED
|
||||
assert p.attempts == 1
|
||||
|
||||
executor.run(config, sess) # attempt 2 -> failed for good
|
||||
assert p.status == ProposalStatus.FAILED
|
||||
assert p.status is ProposalStatus.FAILED
|
||||
assert p.attempts == 2
|
||||
assert calls["n"] == 2
|
||||
|
||||
@@ -120,14 +227,16 @@ def test_http_post_blocked_when_disabled(cfg, sess):
|
||||
plugins.http_post(p, config, sess)
|
||||
|
||||
|
||||
def test_unknown_kind_falls_back_to_log_action(cfg, 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()
|
||||
executor.approve(p.id, "gustavo", cfg(), sess)
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
assert executor.run(cfg(), sess) == 0
|
||||
assert p.status == ProposalStatus.DONE
|
||||
assert p.status is ProposalStatus.DONE
|
||||
|
||||
done = sess.query(AuditEntry).filter_by(action="execute_done").one()
|
||||
assert done.detail["outcome"]["plugin_fallback"] is True
|
||||
|
||||
+407
-35
@@ -2,6 +2,11 @@
|
||||
|
||||
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 (
|
||||
@@ -14,10 +19,23 @@ from cayceops.models import (
|
||||
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)
|
||||
|
||||
def _eml(subject: str, body: str, in_reply_to: str | None = None, msgid: str | None = None) -> bytes:
|
||||
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",
|
||||
@@ -30,36 +48,108 @@ def _eml(subject: str, body: str, in_reply_to: str | None = None, msgid: str | N
|
||||
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:
|
||||
p = Proposal(kind="partnership", title="Acme hotel affiliate program", status=ProposalStatus.APPROVED)
|
||||
"""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()
|
||||
sess.add(
|
||||
MailMessage(
|
||||
direction=MailDirection.OUTBOUND,
|
||||
message_id=OUTBOUND_MSGID,
|
||||
to_addr="partners@acme.example.org",
|
||||
subject="[cayce] partnership inquiry",
|
||||
proposal_id=p.id,
|
||||
draft_status=DraftStatus.SENT,
|
||||
)
|
||||
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
|
||||
assert clf.classify("Unfortunately we cannot accept new partners", config)[0] is MailClassification.REJECTION
|
||||
assert clf.classify("Could you provide your traffic numbers?", config)[0] is MailClassification.QUESTION
|
||||
kind, confidence = clf.classify("grocery list", config)
|
||||
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)
|
||||
@@ -72,10 +162,15 @@ def test_low_confidence_becomes_needs_review(cfg):
|
||||
|
||||
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)
|
||||
raw = _eml(
|
||||
"Re: [cayce] partnership inquiry",
|
||||
"Approved! Welcome to the Acme partner program.",
|
||||
in_reply_to=OUTBOUND_MSGID,
|
||||
)
|
||||
|
||||
stats = maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||
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
|
||||
@@ -90,14 +185,17 @@ def test_poll_threads_approval_and_drafts_reply(cfg, sess):
|
||||
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)
|
||||
fetcher = lambda cfg: [raw]
|
||||
assert maild._poll(cfg(), sess, fetcher=fetcher)["processed"] == 1
|
||||
assert maild._poll(cfg(), sess, fetcher=fetcher)["skipped"] == 1
|
||||
|
||||
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=lambda cfg: [raw])
|
||||
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
|
||||
@@ -107,7 +205,7 @@ def test_unthreaded_approval_needs_review(cfg, sess):
|
||||
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=lambda cfg: [raw])
|
||||
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
|
||||
@@ -117,42 +215,316 @@ def test_question_gets_clarification_draft(cfg, sess):
|
||||
# --- human gate -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_send_blocked_without_approval(cfg, sess, monkeypatch):
|
||||
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=lambda cfg: [raw])
|
||||
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 sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one().draft_status is DraftStatus.DRAFTED
|
||||
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=lambda cfg: [raw])
|
||||
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) == 0
|
||||
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")
|
||||
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
|
||||
|
||||
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
|
||||
assert sent[0][1] == "partners@acme.example.org"
|
||||
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
|
||||
|
||||
row = sess.query(AuditEntry).filter_by(action="send_draft").one()
|
||||
assert "gustavo" in str(row.detail)
|
||||
# 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 to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||
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) == 0
|
||||
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
|
||||
|
||||
@@ -67,3 +67,42 @@ def test_build_escapes_injected_content(cfg, sess, tmp_path):
|
||||
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
|
||||
|
||||
+127
-26
@@ -1,16 +1,30 @@
|
||||
"""publisher tests — calendar, human gate, nostr publish, disabled adapters."""
|
||||
"""publisher tests — calendar, human gate (signed), nostr publish, gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
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 key (never a real nostr key — allowlisted in .gitleaks.toml)
|
||||
TEST_KEY = "11" * 32
|
||||
# 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):
|
||||
@@ -24,11 +38,23 @@ def _item(channel="nostr", theme="atm-fees", scheduled=None, status=ContentStatu
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -63,10 +89,16 @@ def test_approve_gate(cfg, sess):
|
||||
item = _item(status=ContentStatus.DRAFT)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
cfg() # signing env first
|
||||
|
||||
assert publisher._approve(item.id, "gustavo", sess) == 0
|
||||
# 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 publisher._approve(item.id, "gustavo", sess) == 1 # not draft anymore
|
||||
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"
|
||||
@@ -76,9 +108,7 @@ def test_approve_gate(cfg, sess):
|
||||
|
||||
|
||||
def test_publish_due_nostr_happy_path(cfg, sess):
|
||||
item = _item() # approved, scheduled None -> due now
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
item = _approved_item(cfg, sess)
|
||||
|
||||
relays_seen = []
|
||||
|
||||
@@ -101,21 +131,65 @@ def test_publish_due_nostr_happy_path(cfg, sess):
|
||||
|
||||
|
||||
def test_future_scheduled_item_not_published(cfg, sess):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
item = _item(scheduled=datetime.now(UTC) + timedelta(hours=2))
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
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: [])
|
||||
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_nostr_without_config_fails_clean(cfg, sess):
|
||||
item = _item()
|
||||
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
|
||||
@@ -123,15 +197,16 @@ def test_nostr_without_config_fails_clean(cfg, sess):
|
||||
assert item.error and "nostr" in item.error
|
||||
|
||||
|
||||
def test_non_nostr_channels_skipped_by_default(cfg, sess):
|
||||
item = _item(channel="facebook", theme="lightning-abroad")
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
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.APPROVED
|
||||
skip = sess.query(AuditEntry).filter_by(action="publish_skipped").one()
|
||||
assert skip.detail["channel"] == "facebook"
|
||||
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):
|
||||
@@ -160,3 +235,29 @@ def test_unknown_brand_rejected():
|
||||
|
||||
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)
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_scan_files_proposals_with_kind_and_score(cfg, sess):
|
||||
affiliate = by_title["Acme launches affiliate program for hotels"]
|
||||
assert affiliate.kind == "affiliate"
|
||||
assert affiliate.status == ProposalStatus.PROPOSED
|
||||
assert affiliate.risk_score > 0
|
||||
assert affiliate.match_score > 0
|
||||
assert affiliate.dedupe_key
|
||||
|
||||
partner = by_title["Become a payment partner"]
|
||||
|
||||
Reference in New Issue
Block a user