Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7156358f6 | ||
|
|
508371184d | ||
|
|
fc2dcd4fca | ||
|
|
121183ac83 | ||
|
|
b87a51afc0 | ||
|
|
d527164434 | ||
|
|
1c4ba234d0 | ||
|
|
64f89df289 | ||
|
|
f5b026070b | ||
|
|
60300a2f1a | ||
|
|
09fe9199b3 | ||
|
|
029e4f77d7 | ||
|
|
0d24309433 | ||
|
|
53ea15ee51 | ||
|
|
a2de419467 | ||
|
|
13db3996d9 | ||
|
|
aba42a4210 | ||
|
|
3bde785261 | ||
|
|
c855f01da5 | ||
|
|
56b35d3a63 | ||
|
|
ffe425320d | ||
|
|
d77409148c | ||
|
|
903c591a06 | ||
|
|
40453aadb4 | ||
|
|
61d5cfd67d | ||
|
|
c9bd4411e8 | ||
|
|
90e6cfcc19 | ||
|
|
2da5f3ff24 | ||
|
|
2423cd10b8 | ||
|
|
d500d88870 | ||
|
|
a282aedb94 | ||
|
|
abbaf7e4e2 |
@@ -21,6 +21,9 @@ CAYCE_OPS_AUTONOMOUS_KINDS=
|
|||||||
CAYCE_OPS_AUTONOMOUS_DAILY_CAP=0
|
CAYCE_OPS_AUTONOMOUS_DAILY_CAP=0
|
||||||
CAYCE_OPS_MAX_ATTEMPTS=2
|
CAYCE_OPS_MAX_ATTEMPTS=2
|
||||||
CAYCE_OPS_ENABLE_HTTP_ACTIONS=false
|
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) ---
|
# --- mail (IMAP read / SMTP send-approved-drafts-only) ---
|
||||||
CAYCE_OPS_IMAP_HOST=imap.example.org
|
CAYCE_OPS_IMAP_HOST=imap.example.org
|
||||||
|
|||||||
+31
-3
@@ -8,6 +8,21 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
lint-test-leaks:
|
lint-test-leaks:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -23,11 +38,24 @@ jobs:
|
|||||||
- name: Lint (ruff)
|
- name: Lint (ruff)
|
||||||
run: uv run ruff check .
|
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
|
run: uv run alembic upgrade head
|
||||||
|
|
||||||
- name: Tests (pytest)
|
- name: Unit tests (pytest)
|
||||||
run: uv run 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)
|
- name: Secret scan (gitleaks, full history)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
lint-test-leaks:
|
lint-test-leaks:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -23,11 +38,24 @@ jobs:
|
|||||||
- name: Lint (ruff)
|
- name: Lint (ruff)
|
||||||
run: uv run ruff check .
|
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
|
run: uv run alembic upgrade head
|
||||||
|
|
||||||
- name: Tests (pytest)
|
- name: Unit tests (pytest)
|
||||||
run: uv run 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)
|
- name: Secret scan (gitleaks, full history)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# AUDIT — cayce-ops
|
# AUDIT — cayce-ops
|
||||||
|
|
||||||
Audited commit: the merge of PR #6 on `main` (see *Audited commit* at the bottom).
|
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,
|
Gate that commit passed: `make check` — ruff (0 findings), 44 tests green,
|
||||||
alembic smoke on sqlite, gitleaks full history (no leaks).
|
alembic smoke on sqlite, gitleaks full history (no leaks).
|
||||||
|
|
||||||
@@ -146,7 +152,54 @@ docker compose up -d # or run each: uv run cayceops {scanner,executo
|
|||||||
## Audited commit
|
## Audited commit
|
||||||
|
|
||||||
- Repository: `cayce/cayce-ops` (branch `main`)
|
- Repository: `cayce/cayce-ops` (branch `main`)
|
||||||
- Audited: merge of PR #6 (`audit: AUDIT.md for operator review`) — hash printed
|
- **Audited: d500d888701c3f1cd3305d098a448c3e20a8ad99** (merge of PR #7)
|
||||||
in the "Ready for audit" issue that links this file.
|
- Gate at that commit: ruff clean (0 findings) · 44/44 tests · alembic 0001
|
||||||
- Gate at that commit: ruff clean · 44/44 tests · alembic 0001 smoke OK ·
|
smoke OK · gitleaks `--all` no leaks · `make check` exit 0.
|
||||||
gitleaks `--all` no leaks.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response to external audit — 2026-10-20
|
||||||
|
|
||||||
|
External audit verdict reproduced our gate and found 5 critical + 6 medium
|
||||||
|
issues. All criticals and all mediums are fixed in **c9bd441** (PR #9):
|
||||||
|
|
||||||
|
| # | Finding | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| C1 | approval forgeable with any `--actor` | hash-chained `audit_log` (`verify_audit_chain`); optional-required operator BIP-340 signature (`CAYCE_OPS_OPERATOR_PUBKEY`); `deploy/roles.sql` least-privilege DB roles |
|
||||||
|
| C2 | mail threading dead (no OUTBOUND rows, inbound id overwritten, no first-contact) | sends create OUTBOUND rows (own Message-ID + In-Reply-To); inbound id untouched; `maild compose`; chain fallback in `_match_proposal` |
|
||||||
|
| C3 | duplicate sends on SMTP failure | per-message transactions; failures audited (`send_failed`), row stays queued |
|
||||||
|
| C4 | negated approvals classified as approvals; LLM prompt `.format()` KeyError | negation rules first (0.95 REJECTION); prompt braces escaped |
|
||||||
|
| C5 | systemd units ran nonexistent `run` subcommands | `run` added to all five daemons; `publisher plan` timer documented |
|
||||||
|
| M1-M6 | gate not self-installed; approved text unbound; `risk_score` misnamed/ordered; postgres 0.0.0.0; IMAP seen-before-commit; publisher noise/infinite retries | `make hooks`; `approved_hash` bindings; `match_score` (migration 0002) + relevance-desc; `127.0.0.1` bind; `\Seen` post-commit; `SKIPPED` terminal + attempt budget |
|
||||||
|
|
||||||
|
New audited commit: **c9bd441**. Gate at that commit: ruff 0 · 56/56 tests ·
|
||||||
|
alembic 0001→0002 · gitleaks `--all` clean · `make check` exit 0.
|
||||||
|
Still open (declared): apply `roles.sql` on production, provision CI runner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response to audit round 2 — 2026-10-20 (PR #10)
|
||||||
|
|
||||||
|
Round-2 audit closed findings 2, 3, 5 and the mediums; the remaining points
|
||||||
|
were re-implemented in PR #10, one commit per point:
|
||||||
|
|
||||||
|
| Punto | Hallazgo | Commit | Test que lo prueba |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1a/1b/1c | firmas obligatorias atadas a contenido+deployment, completas en fila y audit | `40453aa` audit-1 | `test_signed_approval_mandatory_and_content_bound` |
|
||||||
|
| 1d | verificación al ejecutar/enviar/publicar; SQL-directo se rechaza y revierte | `40453aa` audit-1 | `test_sql_approved_row_is_refused_at_execution`, `test_unsigned_sql_approved_draft_is_refused`, `test_unsigned_sql_approved_item_not_published` |
|
||||||
|
| 1d | to_addr cambiado tras aprobar bloquea envío | `40453aa` audit-1 | `test_changed_to_addr_after_approval_blocks_send` |
|
||||||
|
| 1d | firma de otro deployment/contenido rechazada | `40453aa` audit-1 | `test_foreign_deployment_signature_refused` (executor+maild), `test_foreign_deployment_signature_refused` (publisher) |
|
||||||
|
| 1e | sin ramas `if approved_hash and ...`: binding obligatorio | `40453aa` audit-1 | cubierto por los tests de firma obligatoria |
|
||||||
|
| 1f | fallos de firma auditados desde el CLI | `40453aa` audit-1 | `test_approve_fails_audited_from_cli_wrapper` |
|
||||||
|
| 2 | clasificador: negación en el mail veta approval → needs_review | `903c591` audit-2 | `test_negated_approvals_never_classify_approval` (las 6 oraciones mandadas + 4 extra) |
|
||||||
|
| 3 | roles.sql: mínimo privilegio por daemon, trigger de aprobación, cayce_approver único, sin contraseñas literales | `d774091` audit-3 | `tests/integration/test_pg_roles.py` (PG real, ciclo completo por rol) |
|
||||||
|
| 4 | escrituras serializadas; génesis documentado; anclaje Nostr diario | `ffe4253` audit-4 | `test_concurrent_writers_keep_chain_verifiable`; legacy-genesis en `verify_audit_chain`; `test_anchor_signs_and_logs_once_per_day` |
|
||||||
|
| 5 | CLI de envío usa el código testeado; fallos auditados + daemon_runs; exit ≠ 0 | `56b35d3` audit-5 (nota: refactor dentro de audit-1, commit marcador) | `test_send_failures_audited_and_exit_code` |
|
||||||
|
| 6 | migraciones upgrade/downgrade contra Postgres 16 en CI | `c855f01` audit-6 | job "Migrations upgrade/downgrade round-trip (Postgres 16)" en ci.yml; ejecutado localmente contra Postgres 18.6 (reportado en PR) |
|
||||||
|
| 7 | unidad systemd para `publisher plan` | `3bde785` audit-7 | `deploy/systemd/cayce-ops-plan.{service,timer}` (diaria) |
|
||||||
|
|
||||||
|
Gate en el commit auditado: ruff 0 · 65 unit tests + 1 integración PG ·
|
||||||
|
alembic 0001→0003 con round-trip PG · gitleaks `--all` limpio · `make check` exit 0.
|
||||||
|
|
||||||
|
Declarados abiertos (no resueltos aquí): aplicar `roles.sql` en producción y
|
||||||
|
provisionar el runner de CI — decisión y ejecución del operador.
|
||||||
|
|||||||
@@ -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)
|
sync: ## install locked deps (creates .venv)
|
||||||
uv sync --frozen
|
uv sync --frozen
|
||||||
@@ -17,5 +17,9 @@ migrate-check: ## smoke the alembic chain on a scratch sqlite db
|
|||||||
leaks: ## gitleaks over the full history
|
leaks: ## gitleaks over the full history
|
||||||
gitleaks detect --source . --log-opts="--all" -v
|
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
|
check: lint test migrate-check leaks ## the pre-push gate — must be green before every push
|
||||||
@echo "check: OK"
|
@echo "check: OK"
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
# Cayce is an agent-operated project run by Gustavo.
|
# Cayce Ops
|
||||||
|
|
||||||
Cayce Ops is the autonomous operations stack behind Cayce — an agent that runs
|
CayceOps es el stack de operaciones de Cayce, un experimento de operaciones
|
||||||
[bitcoinhotels.org](https://bitcoinhotels.org) (Bitcoin-native travel guides)
|
agénticas de Cayce Co. Toda acción con efectos externos requiere aprobación
|
||||||
and [hardcap.news](https://hardcap.news) (Bitcoin security intelligence).
|
humana firmada criptográficamente, con una clave que nunca reside en la
|
||||||
|
infraestructura de Cayce.
|
||||||
|
|
||||||
|
Cayce operates [bitcoinhotels.org](https://bitcoinhotels.org)
|
||||||
|
(Bitcoin-native travel guides) and [hardcap.news](https://hardcap.news)
|
||||||
|
(Bitcoin security intelligence).
|
||||||
|
|
||||||
Five decoupled components share one Postgres database. Each is a CLI command,
|
Five decoupled components share one Postgres database. Each is a CLI command,
|
||||||
driven by cron/systemd timers. Nothing runs without an audit trail.
|
driven by cron/systemd timers. Nothing runs without an audit trail.
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""audit_log append-only: reject UPDATE and DELETE (round-2 hardening)
|
||||||
|
|
||||||
|
Revision ID: 0004
|
||||||
|
Revises: 0003
|
||||||
|
Create Date: 2026-10-28
|
||||||
|
|
||||||
|
An audit trail that can be edited is not an audit trail. This migration
|
||||||
|
installs, on every engine:
|
||||||
|
|
||||||
|
- SQLite: BEFORE UPDATE/DELETE triggers that RAISE(ABORT).
|
||||||
|
- Postgres: BEFORE UPDATE/DELETE triggers that RAISE EXCEPTION.
|
||||||
|
|
||||||
|
The writer roles never had UPDATE/DELETE grants in roles.sql; this closes
|
||||||
|
the hole for the table owner and for direct SQL connections.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0004"
|
||||||
|
down_revision = "0003"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
SQLITE_TRIGGERS = [
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS audit_log_no_update
|
||||||
|
BEFORE UPDATE ON audit_log
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'cayce-ops: audit_log is append-only (UPDATE rejected)');
|
||||||
|
END;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS audit_log_no_delete
|
||||||
|
BEFORE DELETE ON audit_log
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'cayce-ops: audit_log is append-only (DELETE rejected)');
|
||||||
|
END;
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
PG_TRIGGERS = """
|
||||||
|
CREATE OR REPLACE FUNCTION cayce_ops_audit_append_only() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'cayce-ops: audit_log is append-only (% rejected)', TG_OP;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if bind.dialect.name == "postgresql":
|
||||||
|
op.execute(PG_TRIGGERS)
|
||||||
|
op.execute(
|
||||||
|
"DROP TRIGGER IF EXISTS audit_log_no_update ON audit_log;"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER audit_log_no_update BEFORE UPDATE ON audit_log "
|
||||||
|
"FOR EACH ROW EXECUTE FUNCTION cayce_ops_audit_append_only();"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"DROP TRIGGER IF EXISTS audit_log_no_delete ON audit_log;"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER audit_log_no_delete BEFORE DELETE ON audit_log "
|
||||||
|
"FOR EACH ROW EXECUTE FUNCTION cayce_ops_audit_append_only();"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for ddl in SQLITE_TRIGGERS:
|
||||||
|
bind.execute(sa_text(ddl))
|
||||||
|
|
||||||
|
|
||||||
|
def sa_text(sql: str):
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
return sa.text(sql)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if bind.dialect.name == "postgresql":
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS audit_log_no_update ON audit_log;")
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS audit_log_no_delete ON audit_log;")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS cayce_ops_audit_append_only();")
|
||||||
|
else:
|
||||||
|
bind.execute(sa_text("DROP TRIGGER IF EXISTS audit_log_no_update;"))
|
||||||
|
bind.execute(sa_text("DROP TRIGGER IF EXISTS audit_log_no_delete;"))
|
||||||
+73
-10
@@ -71,10 +71,16 @@ def executor_run() -> None:
|
|||||||
|
|
||||||
@executor_app.command("approve")
|
@executor_app.command("approve")
|
||||||
def executor_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:
|
) -> None:
|
||||||
"""Approve a proposal (human action, audited)."""
|
"""Approve a proposal (human action, audited; signature mandatory)."""
|
||||||
raise typer.Exit(_load_component("executor").approve(proposal_id, actor))
|
raise typer.Exit(
|
||||||
|
_load_component("executor").approve(proposal_id, actor, signature=signature)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@executor_app.command("reject")
|
@executor_app.command("reject")
|
||||||
@@ -95,17 +101,48 @@ def maild_poll() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@maild_app.command("approve-draft")
|
@maild_app.command("approve-draft")
|
||||||
def maild_approve(msg_id: int, actor: str = typer.Option(...)) -> None:
|
def maild_approve(
|
||||||
"""Approve a drafted reply so SMTP may send it."""
|
msg_id: int,
|
||||||
raise typer.Exit(_load_component("maild").approve_draft(msg_id, actor))
|
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")
|
@maild_app.command("send-approved")
|
||||||
def maild_send() -> None:
|
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())
|
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 ---------------------------------------------------------------
|
||||||
publisher_app = typer.Typer(name="publisher", help="Themed content calendar")
|
publisher_app = typer.Typer(name="publisher", help="Themed content calendar")
|
||||||
app.add_typer(publisher_app)
|
app.add_typer(publisher_app)
|
||||||
@@ -124,9 +161,17 @@ def publisher_image(item_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@publisher_app.command("approve")
|
@publisher_app.command("approve")
|
||||||
def publisher_approve(item_id: int, actor: str = typer.Option(...)) -> None:
|
def publisher_approve(
|
||||||
"""Approve a draft content item."""
|
item_id: int,
|
||||||
raise typer.Exit(_load_component("publisher").approve(item_id, actor))
|
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")
|
@publisher_app.command("publish-due")
|
||||||
@@ -135,6 +180,12 @@ def publisher_publish() -> None:
|
|||||||
raise typer.Exit(_load_component("publisher").publish_due())
|
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 -----------------------------------------------------------------
|
||||||
monitor_app = typer.Typer(name="monitor", help="Static status page")
|
monitor_app = typer.Typer(name="monitor", help="Static status page")
|
||||||
app.add_typer(monitor_app)
|
app.add_typer(monitor_app)
|
||||||
@@ -146,6 +197,18 @@ def monitor_build() -> None:
|
|||||||
raise typer.Exit(_load_component("monitor").build())
|
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
|
def main() -> None: # pragma: no cover
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,91 @@ from cayceops.models import AuditEntry, DaemonRun, Proposal, ProposalStatus, aud
|
|||||||
Actor = str
|
Actor = str
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cfg():
|
||||||
|
from cayceops.config import load
|
||||||
|
|
||||||
|
return load()
|
||||||
|
|
||||||
|
|
||||||
# --- human transitions -------------------------------------------------------
|
# --- 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."""
|
def _signing(cfg: Config) -> tuple[str, str]:
|
||||||
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess)
|
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode.
|
||||||
|
|
||||||
|
Also rejects the agent's own identity as operator (D16): the operator
|
||||||
|
key must belong to a human and must never reside on this server.
|
||||||
|
"""
|
||||||
|
from cayceops.config import ConfigError
|
||||||
|
from cayceops.signing import validate_no_self_approval
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
validate_no_self_approval(cfg)
|
||||||
|
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:
|
def reject(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
|
||||||
@@ -48,6 +127,7 @@ def _decide(
|
|||||||
decision: ProposalStatus,
|
decision: ProposalStatus,
|
||||||
cfg: Config | None,
|
cfg: Config | None,
|
||||||
sess,
|
sess,
|
||||||
|
signature: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
owned = False
|
owned = False
|
||||||
if sess is None:
|
if sess is None:
|
||||||
@@ -70,11 +150,14 @@ def _decide(
|
|||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
proposal.status = decision
|
proposal.status = decision
|
||||||
|
detail: dict = {}
|
||||||
if decision == ProposalStatus.APPROVED:
|
if decision == ProposalStatus.APPROVED:
|
||||||
proposal.approved_by = actor
|
proposal.approved_by = actor
|
||||||
proposal.approved_at = datetime.now(UTC)
|
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"
|
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
|
return 0
|
||||||
finally:
|
finally:
|
||||||
if owned:
|
if owned:
|
||||||
@@ -85,9 +168,20 @@ def _decide(
|
|||||||
|
|
||||||
|
|
||||||
def _auto_approve(cfg: Config, sess) -> int:
|
def _auto_approve(cfg: Config, sess) -> int:
|
||||||
"""Auto-approve proposed items per policy. Returns count approved."""
|
"""DISABLED (audit round 2, point 1a): approvals require operator
|
||||||
if not cfg.autonomous_mode or not cfg.autonomous_kinds:
|
signatures bound to content — there is no non-human approval path.
|
||||||
return 0
|
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)
|
midnight = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
used = (
|
used = (
|
||||||
sess.query(func.count(AuditEntry.id))
|
sess.query(func.count(AuditEntry.id))
|
||||||
@@ -102,13 +196,15 @@ def _auto_approve(cfg: Config, sess) -> int:
|
|||||||
approved = 0
|
approved = 0
|
||||||
if budget == 0:
|
if budget == 0:
|
||||||
return 0
|
return 0
|
||||||
|
# most relevant first (match_score = keyword relevance, NOT risk — the
|
||||||
|
# audit's naming finding; ascending order approved noise first)
|
||||||
candidates = (
|
candidates = (
|
||||||
sess.query(Proposal)
|
sess.query(Proposal)
|
||||||
.filter(
|
.filter(
|
||||||
Proposal.status == ProposalStatus.PROPOSED,
|
Proposal.status == ProposalStatus.PROPOSED,
|
||||||
Proposal.kind.in_(cfg.autonomous_kinds),
|
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)
|
.limit(budget)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -124,7 +220,7 @@ def _auto_approve(cfg: Config, sess) -> int:
|
|||||||
detail={
|
detail={
|
||||||
"reason": "autonomous_mode policy",
|
"reason": "autonomous_mode policy",
|
||||||
"kind": proposal.kind,
|
"kind": proposal.kind,
|
||||||
"risk_score": proposal.risk_score,
|
"match_score": proposal.match_score,
|
||||||
"daily_cap": cfg.autonomous_daily_cap,
|
"daily_cap": cfg.autonomous_daily_cap,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -135,8 +231,67 @@ def _auto_approve(cfg: Config, sess) -> int:
|
|||||||
# --- execution ----------------------------------------------------------------
|
# --- 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:
|
def execute_one(proposal: Proposal, cfg: Config, sess) -> bool:
|
||||||
"""Run the plugin for one approved proposal. Returns True on success."""
|
"""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.status = ProposalStatus.RUNNING
|
||||||
proposal.attempts += 1
|
proposal.attempts += 1
|
||||||
audit(
|
audit(
|
||||||
@@ -203,6 +358,13 @@ def run(cfg: Config | None = None, sess=None) -> int:
|
|||||||
run_row = DaemonRun(daemon="executor")
|
run_row = DaemonRun(daemon="executor")
|
||||||
sess.add(run_row)
|
sess.add(run_row)
|
||||||
sess.flush()
|
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:
|
try:
|
||||||
auto = _auto_approve(cfg, sess)
|
auto = _auto_approve(cfg, sess)
|
||||||
pending = (
|
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``
|
Approval rule (audit round 2, point 2): a mail may only classify `approval`
|
||||||
confidence becomes ``needs_review``. With the ``null`` provider (default) only
|
when NO sentence contains a negation. Any negation token anywhere in the mail
|
||||||
deterministic rules run.
|
(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
|
from __future__ import annotations
|
||||||
@@ -13,16 +17,58 @@ import re
|
|||||||
from cayceops.config import Config
|
from cayceops.config import Config
|
||||||
from cayceops.models import MailClassification
|
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.
|
||||||
|
# English and Spanish (merchants write in both; mixed mails are common).
|
||||||
|
_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|"
|
||||||
|
# spanish: standalone negation words
|
||||||
|
r"nunca|jam[aá]s|tampoco|ning[uú]n(?:a|o)?|nadie|nada|"
|
||||||
|
# spanish: multi-word negations and rejection stems
|
||||||
|
r"no\s+podemos|no\s+es\s+posible|no\s+acept(?:amos|ar[aá])|"
|
||||||
|
r"rechaz\w*|lamentablemente|descartad\w*|"
|
||||||
|
r"no\s+ha\s+sido\s+aprobad\w*|no\s+procede)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
_RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
|
_RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
|
||||||
# (class, pattern, confidence when matched)
|
# (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.REJECTION,
|
||||||
(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),
|
# 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.REJECTION,
|
||||||
|
# spanish rejections (strong stems, not just negation words)
|
||||||
|
re.compile(r"\b(rechaz\w*|no\s+vamos\s+a\s+pod(?:er|emos)|su\s+solicitud\s+ha\s+sido\s+denegada|no\s+cumple\s+los\s+requisitos)\b", 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.APPROVAL,
|
||||||
|
# spanish approvals (standalone positive stems; the veto still
|
||||||
|
# downgrades if ANY negation appears elsewhere in the mail)
|
||||||
|
re.compile(r"\b(aprob\w*|aceptad\w*|bienvenid\w*|ha\s+sido\s+aceptada|encantados?\s+de\s+(trabajar|colaborar))\b", 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:
|
_LLM_PROMPT = """Classify this merchant email into exactly one of:
|
||||||
approval, rejection, question, other.
|
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:
|
Subject and body:
|
||||||
{body}"""
|
{body}"""
|
||||||
@@ -35,13 +81,20 @@ def _rules(text: str) -> tuple[MailClassification, float]:
|
|||||||
return MailClassification.OTHER, 0.3
|
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:
|
def _llm_classify(text: str, cfg: Config) -> tuple[MailClassification, float] | None:
|
||||||
"""Ask the configured LLM. Returns None on any failure (rules take over)."""
|
"""Ask the configured LLM. Returns None on any failure (rules take over)."""
|
||||||
from cayceops.llm import get_provider
|
from cayceops.llm import get_provider
|
||||||
|
|
||||||
try:
|
try:
|
||||||
provider = get_provider(cfg)
|
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))
|
data = json.loads(re.search(r"\{.*\}", raw, re.S).group(0))
|
||||||
kind = MailClassification(data["classification"].lower())
|
kind = MailClassification(data["classification"].lower())
|
||||||
confidence = float(data["confidence"])
|
confidence = float(data["confidence"])
|
||||||
@@ -63,6 +116,13 @@ def classify(text: str, cfg: Config) -> tuple[MailClassification, float]:
|
|||||||
else:
|
else:
|
||||||
kind, confidence = _rules(text)
|
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:
|
if confidence < cfg.llm_review_threshold:
|
||||||
return MailClassification.NEEDS_REVIEW, confidence
|
return MailClassification.NEEDS_REVIEW, confidence
|
||||||
return kind, confidence
|
return kind, confidence
|
||||||
|
|||||||
@@ -35,9 +35,31 @@ from cayceops.models import (
|
|||||||
MailDirection,
|
MailDirection,
|
||||||
MailMessage,
|
MailMessage,
|
||||||
Proposal,
|
Proposal,
|
||||||
|
_content_hash,
|
||||||
audit,
|
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.
|
||||||
|
|
||||||
|
Also rejects the agent's own identity as operator (D16): the operator
|
||||||
|
key must belong to a human and must never reside on this server.
|
||||||
|
"""
|
||||||
|
from cayceops.config import ConfigError
|
||||||
|
from cayceops.signing import validate_no_self_approval
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
validate_no_self_approval(cfg)
|
||||||
|
return cfg.deployment_id, cfg.operator_pubkey
|
||||||
|
|
||||||
|
|
||||||
# --- IMAP ---------------------------------------------------------------------
|
# --- IMAP ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -48,17 +70,33 @@ def connect_imap(cfg: Config) -> imaplib.IMAP4_SSL:
|
|||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
def _fetch_unread(cfg: Config) -> list[bytes]:
|
def _fetch_unread(cfg: Config) -> list[tuple[bytes, bytes]]:
|
||||||
"""Fetch raw unread messages. Returns list of raw RFC822 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)
|
client = connect_imap(cfg)
|
||||||
try:
|
try:
|
||||||
_, data = client.search(None, "UNSEEN")
|
_, data = client.search(None, "UNSEEN")
|
||||||
raw_messages: list[bytes] = []
|
out: list[tuple[bytes, bytes]] = []
|
||||||
for num in data[0].split():
|
for num in data[0].split():
|
||||||
_, msg_data = client.fetch(num, "(RFC822)")
|
_, 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")
|
client.store(num, "+FLAGS", "\\Seen")
|
||||||
return raw_messages
|
|
||||||
finally:
|
finally:
|
||||||
client.logout()
|
client.logout()
|
||||||
|
|
||||||
@@ -96,7 +134,13 @@ def parse_raw(raw: bytes) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _match_proposal(parsed: dict, sess) -> Proposal | None:
|
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")
|
irt = parsed.get("in_reply_to")
|
||||||
if not irt:
|
if not irt:
|
||||||
return None
|
return None
|
||||||
@@ -108,9 +152,16 @@ def _match_proposal(parsed: dict, sess) -> Proposal | None:
|
|||||||
)
|
)
|
||||||
.one_or_none()
|
.one_or_none()
|
||||||
)
|
)
|
||||||
if sent is None or sent.proposal_id is None:
|
if sent is not None and sent.proposal_id is not None:
|
||||||
return None
|
return sess.get(Proposal, sent.proposal_id)
|
||||||
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 -------------------------------------------------------------------
|
# --- drafts -------------------------------------------------------------------
|
||||||
@@ -144,10 +195,15 @@ def _build_draft(parsed: dict, kind: MailClassification, proposal: Proposal | No
|
|||||||
# --- inbound pass ---------------------------------------------------------------
|
# --- inbound pass ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _poll(cfg: Config, sess, fetcher=None) -> dict:
|
def _poll(cfg: Config, sess, fetcher=None, marker=None) -> tuple[dict, list[bytes]]:
|
||||||
"""One inbound pass: parse, classify, thread, draft. Returns stats."""
|
"""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}
|
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)
|
parsed = parse_raw(raw)
|
||||||
if (
|
if (
|
||||||
sess.query(MailMessage)
|
sess.query(MailMessage)
|
||||||
@@ -186,6 +242,7 @@ def _poll(cfg: Config, sess, fetcher=None) -> dict:
|
|||||||
detail={"classification": kind.value, "confidence": confidence},
|
detail={"classification": kind.value, "confidence": confidence},
|
||||||
)
|
)
|
||||||
stats["processed"] += 1
|
stats["processed"] += 1
|
||||||
|
processed_nums.append(num) # stored -> safe to mark seen after commit
|
||||||
|
|
||||||
if kind == MailClassification.NEEDS_REVIEW:
|
if kind == MailClassification.NEEDS_REVIEW:
|
||||||
stats["needs_review"] += 1
|
stats["needs_review"] += 1
|
||||||
@@ -210,18 +267,42 @@ def _poll(cfg: Config, sess, fetcher=None) -> dict:
|
|||||||
detail={"mail_id": record.id},
|
detail={"mail_id": record.id},
|
||||||
)
|
)
|
||||||
stats["drafted"] += 1
|
stats["drafted"] += 1
|
||||||
return stats
|
return stats, processed_nums
|
||||||
|
|
||||||
|
|
||||||
# --- human gate: approve draft -------------------------------------------------
|
# --- human gate: approve draft -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _approve_draft(mail_id: int, actor: str, sess) -> int:
|
def _mail_to_addr(record) -> str | None:
|
||||||
"""Human approves a drafted reply. Only then may SMTP send it."""
|
"""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)
|
record = sess.get(MailMessage, mail_id)
|
||||||
if record is None or record.direction != MailDirection.INBOUND:
|
if record is None:
|
||||||
return 1
|
return 1
|
||||||
if record.draft_status != DraftStatus.DRAFTED:
|
if record.draft_status != DraftStatus.DRAFTED or not record.draft_reply:
|
||||||
audit(
|
audit(
|
||||||
sess,
|
sess,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
@@ -231,13 +312,52 @@ def _approve_draft(mail_id: int, actor: str, sess) -> int:
|
|||||||
detail={"reason": f"invalid draft state: {record.draft_status.value}"},
|
detail={"reason": f"invalid draft state: {record.draft_status.value}"},
|
||||||
)
|
)
|
||||||
return 1
|
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.draft_status = DraftStatus.APPROVED
|
||||||
|
record.approved_hash = _content_hash(record.draft_reply)
|
||||||
|
record.approved_by = actor[:128]
|
||||||
|
record.operator_signature = signature # FULL signature
|
||||||
audit(
|
audit(
|
||||||
sess,
|
sess,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
action="approve_draft",
|
action="approve_draft",
|
||||||
proposal_id=record.proposal_id,
|
proposal_id=record.proposal_id,
|
||||||
detail={"mail_id": record.id},
|
detail={"mail_id": record.id, "hash": record.approved_hash, "signature": signature},
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -245,12 +365,21 @@ def _approve_draft(mail_id: int, actor: str, sess) -> int:
|
|||||||
# --- SMTP: only approved drafts ------------------------------------------------
|
# --- 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 = EmailMessage()
|
||||||
msg["From"] = cfg.mail_from
|
msg["From"] = cfg.mail_from
|
||||||
msg["To"] = to_addr
|
msg["To"] = to_addr
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
msg["Message-ID"] = message_id
|
msg["Message-ID"] = message_id
|
||||||
|
if in_reply_to:
|
||||||
|
msg["In-Reply-To"] = in_reply_to # keep merchant threads intact
|
||||||
msg.set_content(body)
|
msg.set_content(body)
|
||||||
with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) as server:
|
with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) as server:
|
||||||
server.starttls()
|
server.starttls()
|
||||||
@@ -267,52 +396,208 @@ def _approved_by(sess, mail_id: int) -> str:
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
def _send_approved(cfg: Config, sess, sender=None) -> dict:
|
def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
|
||||||
"""Send every APPROVED draft. Anything else stays untouched."""
|
"""Send ONE approved draft. Returns: sent | hash_mismatch | missing | blocked.
|
||||||
stats = {"sent": 0, "blocked": 0}
|
|
||||||
pending = (
|
Creates the OUTBOUND row (its own Message-ID + In-Reply-To pointing at the
|
||||||
sess.query(MailMessage)
|
inbound message) so future replies thread correctly — the inbound row's
|
||||||
.filter(
|
message_id is left untouched (audit finding: it used to be overwritten).
|
||||||
MailMessage.direction == MailDirection.INBOUND,
|
The caller owns the transaction: one message per commit, so an SMTP failure
|
||||||
MailMessage.draft_status == DraftStatus.APPROVED,
|
on message N never un-sends messages 1..N-1 (audit finding: duplicates).
|
||||||
)
|
"""
|
||||||
.all()
|
|
||||||
)
|
from cayceops.config import ConfigError
|
||||||
for record in pending:
|
from cayceops.signing import verify_signature
|
||||||
to_addr = record.from_addr or ""
|
|
||||||
if not to_addr:
|
record = sess.get(MailMessage, mail_id)
|
||||||
record.draft_status = DraftStatus.SKIPPED
|
if record is None:
|
||||||
stats["blocked"] += 1
|
return "missing"
|
||||||
continue
|
if record.draft_status != DraftStatus.APPROVED:
|
||||||
message_id = (
|
return "blocked"
|
||||||
f"<cayce-draft-{record.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>"
|
|
||||||
)
|
# run-time signature gate (audit round 2, 1d): the row must carry a valid
|
||||||
(sender or _smtp_send)(
|
# operator signature for its CURRENT to_addr/subject/body + deployment.
|
||||||
cfg,
|
# Unsigned rows (raw SQL approval) or edited ones revert to DRAFTED.
|
||||||
to_addr,
|
try:
|
||||||
f"Re: {record.subject or 'your message'}",
|
deployment_id, pubkey = _signing(cfg)
|
||||||
record.draft_reply or "",
|
except ConfigError as exc:
|
||||||
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
|
|
||||||
audit(
|
audit(
|
||||||
sess,
|
sess,
|
||||||
actor="daemon:maild",
|
actor="daemon:maild",
|
||||||
action="send_draft",
|
action="send_refused",
|
||||||
proposal_id=record.proposal_id,
|
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={
|
detail={
|
||||||
"mail_id": record.id,
|
"mail_id": record.id,
|
||||||
"message_id": message_id,
|
"reason": "no valid operator signature for current content/deployment",
|
||||||
"approved_by": approved_by,
|
"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
|
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) ------------------------------------
|
# --- CLI wrappers (session scope lives here) ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -322,12 +607,13 @@ def poll(cfg: Config | None = None) -> int:
|
|||||||
|
|
||||||
cfg = cfg or load()
|
cfg = cfg or load()
|
||||||
try:
|
try:
|
||||||
|
nums: list[bytes] = []
|
||||||
with session_scope() as sess:
|
with session_scope() as sess:
|
||||||
run_row = DaemonRun(daemon="maild")
|
run_row = DaemonRun(daemon="maild")
|
||||||
sess.add(run_row)
|
sess.add(run_row)
|
||||||
sess.flush()
|
sess.flush()
|
||||||
try:
|
try:
|
||||||
stats = _poll(cfg, sess)
|
stats, nums = _poll(cfg, sess)
|
||||||
except Exception as exc: # noqa: BLE001 — daemon boundary
|
except Exception as exc: # noqa: BLE001 — daemon boundary
|
||||||
run_row.status = "error"
|
run_row.status = "error"
|
||||||
run_row.detail = {"error": str(exc)[:300]}
|
run_row.detail = {"error": str(exc)[:300]}
|
||||||
@@ -336,29 +622,66 @@ def poll(cfg: Config | None = None) -> int:
|
|||||||
run_row.status = "ok"
|
run_row.status = "ok"
|
||||||
run_row.detail = stats
|
run_row.detail = stats
|
||||||
run_row.finished_at = datetime.now(UTC)
|
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
|
return 0
|
||||||
finally:
|
finally:
|
||||||
engine().dispose()
|
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
|
from cayceops.db import engine
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with session_scope() as sess:
|
with session_scope() as sess:
|
||||||
return _approve_draft(mail_id, actor, sess)
|
return _approve_draft(mail_id, actor, sess, signature)
|
||||||
finally:
|
finally:
|
||||||
engine().dispose()
|
engine().dispose()
|
||||||
|
|
||||||
|
|
||||||
def send_approved(cfg: Config | None = None) -> int:
|
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.config import load
|
||||||
from cayceops.db import engine
|
from cayceops.db import engine, session_factory
|
||||||
|
|
||||||
cfg = cfg or load()
|
cfg = cfg or load()
|
||||||
try:
|
try:
|
||||||
|
factory = session_factory()
|
||||||
with session_scope() as sess:
|
with session_scope() as sess:
|
||||||
_send_approved(cfg, sess)
|
run_row = DaemonRun(daemon="maild-send")
|
||||||
return 0
|
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:
|
finally:
|
||||||
engine().dispose()
|
engine().dispose()
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ DAEMONS = ("scanner", "executor", "maild", "publisher", "monitor")
|
|||||||
_STYLE = """<style>
|
_STYLE = """<style>
|
||||||
body{font-family:ui-monospace,Menlo,monospace;background:#0a0908;color:#f2ece0;margin:2rem}
|
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}
|
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}
|
.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>"""
|
</style>"""
|
||||||
|
|
||||||
|
|
||||||
@@ -49,33 +51,45 @@ def _daemon_rows(sess) -> str:
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if last is None:
|
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:
|
else:
|
||||||
cls = "ok" if last.status == "ok" else "error"
|
cls = "ok" if last.status == "ok" else "error"
|
||||||
|
when = _fmt_ts(last.finished_at or last.started_at)
|
||||||
rows.append(
|
rows.append(
|
||||||
f"<tr><td>{html.escape(name)}</td>"
|
"<tr><td>" + html.escape(name) + "</td>"
|
||||||
f"<td class={cls}>{html.escape(last.status)}</td>"
|
+ '<td class="' + cls + '">' + html.escape(last.status) + "</td>"
|
||||||
f"<td>{_fmt_ts(last.finished_at or last.started_at)}</td></tr>"
|
+ "<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:
|
def _queue_cards(sess) -> str:
|
||||||
counts = dict(sess.query(Proposal.status, func.count(Proposal.id)).group_by(Proposal.status).all())
|
counts = dict(
|
||||||
cards = [
|
sess.query(Proposal.status, func.count(Proposal.id)).group_by(Proposal.status).all()
|
||||||
f'<div class=card>{html.escape(status.value)}: <strong>{counts.get(status, 0)}</strong></div>'
|
)
|
||||||
for status in ProposalStatus
|
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)
|
return "".join(cards)
|
||||||
|
|
||||||
|
|
||||||
def _recent_audit(sess, limit: int = 20) -> str:
|
def _recent_audit(sess, limit: int = 20) -> str:
|
||||||
rows = []
|
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"
|
cls = "ok" if entry.result == "ok" else "error"
|
||||||
rows.append(
|
rows.append(
|
||||||
f"<tr><td>{_fmt_ts(entry.ts)}</td><td>{html.escape(entry.actor)}</td>"
|
"<tr><td>" + _fmt_ts(entry.ts) + "</td>"
|
||||||
f"<td>{html.escape(entry.action)}</td><td class={cls}>{html.escape(entry.result)}</td></tr>"
|
+ "<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>"
|
body = "".join(rows) or "<tr><td colspan=4 class=muted>no activity yet</td></tr>"
|
||||||
return (
|
return (
|
||||||
@@ -133,6 +147,85 @@ def _build(cfg: Config, sess) -> str:
|
|||||||
return cfg.monitor_output
|
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:
|
def build(cfg: Config | None = None) -> int:
|
||||||
"""CLI entry: render the status page inside a DaemonRun bookkeeping wrap."""
|
"""CLI entry: render the status page inside a DaemonRun bookkeeping wrap."""
|
||||||
from cayceops.config import load
|
from cayceops.config import load
|
||||||
@@ -158,3 +251,120 @@ def build(cfg: Config | None = None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
engine().dispose()
|
engine().dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_chain_hash(sess) -> str | None:
|
||||||
|
"""Last non-NULL entry_hash in the audit chain (None if chain empty)."""
|
||||||
|
from cayceops.models import AuditEntry
|
||||||
|
|
||||||
|
last = (
|
||||||
|
sess.query(AuditEntry)
|
||||||
|
.filter(AuditEntry.entry_hash.isnot(None))
|
||||||
|
.order_by(AuditEntry.id.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return last.entry_hash if last else None
|
||||||
|
|
||||||
|
|
||||||
|
def _published_anchors(sess) -> list[dict]:
|
||||||
|
"""Anchor audit entries recorded locally, oldest first."""
|
||||||
|
from cayceops.models import AuditEntry
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
sess.query(AuditEntry)
|
||||||
|
.filter(AuditEntry.action == "anchor")
|
||||||
|
.order_by(AuditEntry.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"event_id": (r.detail or {}).get("event_id"),
|
||||||
|
"last_hash": (r.detail or {}).get("last_hash"),
|
||||||
|
"last_entry": (r.detail or {}).get("last_entry"),
|
||||||
|
"ts": r.ts.isoformat(),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def verify_chain_against_anchors(
|
||||||
|
sess,
|
||||||
|
relay_fetcher=None,
|
||||||
|
) -> dict:
|
||||||
|
"""Compare the local chain tail with the anchors published to Nostr.
|
||||||
|
|
||||||
|
relay_fetcher(deployment) -> list of note contents (strings) found on
|
||||||
|
the relays for this deployment's anchor tag. When None, only local
|
||||||
|
coherence is checked and the result says so.
|
||||||
|
|
||||||
|
Returns a report dict:
|
||||||
|
status: "no_anchors" | "local_only" | "ok" | "mismatch"
|
||||||
|
detail: human-readable explanation
|
||||||
|
local_last_hash, local_anchors, relay_notes (when fetched)
|
||||||
|
"""
|
||||||
|
local_hash = _latest_chain_hash(sess)
|
||||||
|
anchors = _published_anchors(sess)
|
||||||
|
|
||||||
|
report: dict = {
|
||||||
|
"local_last_hash": local_hash,
|
||||||
|
"local_anchors": anchors,
|
||||||
|
"relay_notes": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not anchors:
|
||||||
|
report["status"] = "no_anchors"
|
||||||
|
report["detail"] = (
|
||||||
|
"the chain has never been anchored to Nostr — no external "
|
||||||
|
"attestation exists yet (anchor once with `monitor anchor`)"
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
last_anchor = anchors[-1]
|
||||||
|
if relay_fetcher is None:
|
||||||
|
report["status"] = "local_only"
|
||||||
|
report["detail"] = (
|
||||||
|
"compared only against the LOCAL record of past anchors "
|
||||||
|
"(no relay fetch configured); last anchored hash "
|
||||||
|
f"{last_anchor['last_hash']}"
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
from cayceops.config import load as _load
|
||||||
|
|
||||||
|
deployment = _load().deployment_id
|
||||||
|
notes = relay_fetcher(deployment) or []
|
||||||
|
report["relay_notes"] = notes
|
||||||
|
|
||||||
|
if not notes:
|
||||||
|
report["status"] = "no_anchors"
|
||||||
|
report["detail"] = (
|
||||||
|
"local anchor records exist but no anchor notes were found on "
|
||||||
|
"the relays — the external attestation is missing or was deleted"
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
# the freshest relay note must attest the current chain tail
|
||||||
|
expected_fragment = f"last_hash={local_hash}"
|
||||||
|
latest_note = notes[-1]
|
||||||
|
if expected_fragment in latest_note:
|
||||||
|
report["status"] = "ok"
|
||||||
|
report["detail"] = "latest relay anchor matches the current chain tail"
|
||||||
|
return report
|
||||||
|
|
||||||
|
# maybe the chain advanced after the last anchor — that's fine ONLY if
|
||||||
|
# the note matches the hash recorded in the last local anchor entry
|
||||||
|
if last_anchor["last_hash"] and last_anchor["last_hash"] in latest_note:
|
||||||
|
report["status"] = "ok"
|
||||||
|
report["detail"] = (
|
||||||
|
"relay anchor matches the last local anchor; the chain has "
|
||||||
|
"advanced since (anchor again to attest the new tail)"
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
report["status"] = "mismatch"
|
||||||
|
report["detail"] = (
|
||||||
|
f"relay anchor does not attest chain tail {local_hash} nor the last "
|
||||||
|
f"local anchor {last_anchor['last_hash']} — the published anchor "
|
||||||
|
"does not correspond to this database"
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|||||||
@@ -15,9 +15,19 @@ WIDTH, HEIGHT = 1080, 1350
|
|||||||
|
|
||||||
_BRANDS = {
|
_BRANDS = {
|
||||||
# bitcoinhotels — warm gold on near-black
|
# 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
|
# 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:
|
if pub_point is None:
|
||||||
return False
|
return False
|
||||||
e = int.from_bytes(tag_hash("BIP0340/challenge", sig32[:32] + xonly_pub32 + msg32), "big") % N
|
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:
|
if r_point is None or r_point[1] % 2 != 0:
|
||||||
return False
|
return False
|
||||||
return r_point[0] == r
|
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"]:
|
if hashlib.sha256(serialized.encode("utf-8")).hexdigest() != event["id"]:
|
||||||
return False
|
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(
|
return _schnorr_verify(
|
||||||
bytes.fromhex(event["sig"]), bytes.fromhex(event["id"]), compressed[1:]
|
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:
|
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."""
|
"""Publish one event to one relay; return the relay's OK message."""
|
||||||
from websockets.sync.client import connect
|
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])
|
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)."""
|
"""Publish to every relay; report per-relay outcome (never raises unless all fail)."""
|
||||||
results = []
|
results = []
|
||||||
for relay in relays:
|
for relay in relays:
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ from datetime import UTC, datetime, timedelta
|
|||||||
from cayceops.components.publisher import images, nostr
|
from cayceops.components.publisher import images, nostr
|
||||||
from cayceops.config import Config
|
from cayceops.config import Config
|
||||||
from cayceops.db import session_scope
|
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
|
# rotation: one theme per slot; channel rotation nostr -> facebook -> instagram
|
||||||
THEMES = [
|
THEMES = [
|
||||||
@@ -37,10 +43,22 @@ def _caption(theme: str, link: str) -> str:
|
|||||||
"elsalvador-budget": "A real weekly budget on a Bitcoin standard.",
|
"elsalvador-budget": "A real weekly budget on a Bitcoin standard.",
|
||||||
}
|
}
|
||||||
bodies = {
|
bodies = {
|
||||||
"lightning-abroad": "No foreign transaction fees, no dynamic currency conversion, no declines. Field-tested guide.",
|
"lightning-abroad": (
|
||||||
"seed-strategy": "Steel plate, memorized + decoy, or split custody — the full breakdown, brutally honest.",
|
"No foreign transaction fees, no dynamic currency conversion, "
|
||||||
"atm-fees": "The prepared traveler pays 4-6%. The difference is knowing the operator and the no-KYC cap.",
|
"no declines. Field-tested guide."
|
||||||
"elsalvador-budget": "Surf hostels, comidas, coworking — the corners where you still need dollars, and the 90-day protocol.",
|
),
|
||||||
|
"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(
|
return _CAPTION_TEMPLATE.format(
|
||||||
hook=hooks.get(theme, theme.replace("-", " ").title()),
|
hook=hooks.get(theme, theme.replace("-", " ").title()),
|
||||||
@@ -83,7 +101,12 @@ def _plan(cfg: Config, sess, days: int = 4) -> dict:
|
|||||||
)
|
)
|
||||||
sess.add(item)
|
sess.add(item)
|
||||||
sess.flush()
|
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
|
created += 1
|
||||||
return {"created": created}
|
return {"created": created}
|
||||||
|
|
||||||
@@ -91,7 +114,40 @@ def _plan(cfg: Config, sess, days: int = 4) -> dict:
|
|||||||
# --- human gate -------------------------------------------------------------------
|
# --- 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]:
|
||||||
|
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode.
|
||||||
|
|
||||||
|
Also rejects the agent's own identity as operator (D16): the operator
|
||||||
|
key must belong to a human and must never reside on this server.
|
||||||
|
"""
|
||||||
|
from cayceops.config import ConfigError
|
||||||
|
from cayceops.signing import validate_no_self_approval
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
validate_no_self_approval(cfg)
|
||||||
|
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)
|
item = sess.get(ContentItem, item_id)
|
||||||
if item is None or item.status != ContentStatus.DRAFT:
|
if item is None or item.status != ContentStatus.DRAFT:
|
||||||
audit(
|
audit(
|
||||||
@@ -102,11 +158,58 @@ def _approve(item_id: int, actor: str, sess) -> int:
|
|||||||
detail={"reason": f"item {item_id} not in draft state"},
|
detail={"reason": f"item {item_id} not in draft state"},
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
try:
|
||||||
|
deployment_id, pubkey = _signing(cfg_from(sess))
|
||||||
|
except ConfigError as exc:
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor=actor,
|
||||||
|
action="approve_content",
|
||||||
|
result="error",
|
||||||
|
detail={"reason": str(exc)},
|
||||||
|
)
|
||||||
|
return GATE_ERROR
|
||||||
|
chash = _content_binding(item)
|
||||||
|
try:
|
||||||
|
if not signature:
|
||||||
|
raise SignatureError("signature required")
|
||||||
|
if not verify_signature(
|
||||||
|
"content", deployment_id, item_id, chash, signature, pubkey
|
||||||
|
):
|
||||||
|
raise SignatureError("signature invalid for this content/deployment")
|
||||||
|
except SignatureError as exc:
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor=actor,
|
||||||
|
action="approve_content",
|
||||||
|
result="error",
|
||||||
|
detail={"reason": str(exc), "content_hash": chash},
|
||||||
|
)
|
||||||
|
return GATE_ERROR
|
||||||
item.status = ContentStatus.APPROVED
|
item.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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cfg_from(sess):
|
||||||
|
"""Config from env (publisher commands are single-invocation CLIs)."""
|
||||||
|
from cayceops.config import load
|
||||||
|
|
||||||
|
return load()
|
||||||
|
|
||||||
|
|
||||||
# --- image rendering ----------------------------------------------------------------
|
# --- image rendering ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -132,7 +235,9 @@ def _render_image(item: ContentItem, cfg: Config) -> str | None:
|
|||||||
|
|
||||||
def _publish_nostr(item: ContentItem, cfg: Config, sess, sender=None) -> str:
|
def _publish_nostr(item: ContentItem, cfg: Config, sess, sender=None) -> str:
|
||||||
if not cfg.nostr_private_key or not cfg.nostr_relays:
|
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)
|
secret = nostr.normalize_key(cfg.nostr_private_key)
|
||||||
event = nostr.build_event(secret, item.caption, tags=[["t", item.theme]])
|
event = nostr.build_event(secret, item.caption, tags=[["t", item.theme]])
|
||||||
if not nostr.verify_event(event): # pragma: no cover — build/verify symmetry
|
if not nostr.verify_event(event): # pragma: no cover — build/verify symmetry
|
||||||
@@ -150,7 +255,7 @@ def _publish_nostr(item: ContentItem, cfg: Config, sess, sender=None) -> str:
|
|||||||
def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
|
def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
|
||||||
"""Publish approved items that are due. Non-nostr channels are skipped
|
"""Publish approved items that are due. Non-nostr channels are skipped
|
||||||
unless their adapters are explicitly enabled (default: skipped, audited)."""
|
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 = (
|
due = (
|
||||||
sess.query(ContentItem)
|
sess.query(ContentItem)
|
||||||
.filter(ContentItem.status == ContentStatus.APPROVED)
|
.filter(ContentItem.status == ContentStatus.APPROVED)
|
||||||
@@ -160,31 +265,83 @@ def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
|
|||||||
for item in due:
|
for item in due:
|
||||||
if item.scheduled_for and item.scheduled_for > now:
|
if item.scheduled_for and item.scheduled_for > now:
|
||||||
continue # not due yet
|
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:
|
try:
|
||||||
if item.channel == "nostr":
|
if item.channel == "nostr":
|
||||||
item.post_ref = _publish_nostr(item, cfg, sess, sender=nostr_sender)
|
item.post_ref = _publish_nostr(item, cfg, sess, sender=nostr_sender)
|
||||||
else:
|
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(
|
audit(
|
||||||
sess,
|
sess,
|
||||||
actor="daemon:publisher",
|
actor="daemon:publisher",
|
||||||
action="publish_skipped",
|
action="publish_skipped_terminal",
|
||||||
detail={
|
detail={"item": item.id, "channel": item.channel},
|
||||||
"item": item.id,
|
|
||||||
"channel": item.channel,
|
|
||||||
"reason": f"{item.channel} adapter disabled by default",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
stats["skipped"] += 1
|
stats["skipped"] += 1
|
||||||
continue
|
continue
|
||||||
except Exception as exc: # noqa: BLE001 — publish boundary: audit and continue
|
except Exception as exc: # noqa: BLE001 — publish boundary: audit and continue
|
||||||
|
item.attempts += 1
|
||||||
item.error = str(exc)[:300]
|
item.error = str(exc)[:300]
|
||||||
audit(
|
if item.attempts >= cfg.max_attempts:
|
||||||
sess,
|
# attempt budget exhausted -> terminal (no infinite retries)
|
||||||
actor="daemon:publisher",
|
item.status = ContentStatus.SKIPPED
|
||||||
action="publish_failed",
|
audit(
|
||||||
result="error",
|
sess,
|
||||||
detail={"item": item.id, "error": item.error},
|
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
|
stats["failed"] += 1
|
||||||
continue
|
continue
|
||||||
item.status = ContentStatus.PUBLISHED
|
item.status = ContentStatus.PUBLISHED
|
||||||
@@ -231,6 +388,13 @@ def publish_due(cfg: Config | None = None) -> int:
|
|||||||
run_row = DaemonRun(daemon="publisher")
|
run_row = DaemonRun(daemon="publisher")
|
||||||
sess.add(run_row)
|
sess.add(run_row)
|
||||||
sess.flush()
|
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)
|
stats = _publish_due(cfg, sess)
|
||||||
run_row.status = "ok"
|
run_row.status = "ok"
|
||||||
run_row.detail = stats
|
run_row.detail = stats
|
||||||
@@ -256,11 +420,11 @@ def make_image(item_id: int, cfg: Config | None = None) -> int:
|
|||||||
engine().dispose()
|
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
|
from cayceops.db import engine
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with session_scope() as sess:
|
with session_scope() as sess:
|
||||||
return _approve(item_id, actor, sess)
|
return _approve(item_id, actor, sess, signature)
|
||||||
finally:
|
finally:
|
||||||
engine().dispose()
|
engine().dispose()
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ def scan(cfg: Config, sess, fetch=_fetch) -> dict:
|
|||||||
"summary": desc,
|
"summary": desc,
|
||||||
"scanned_at": datetime.now(UTC).isoformat(),
|
"scanned_at": datetime.now(UTC).isoformat(),
|
||||||
},
|
},
|
||||||
risk_score=score,
|
match_score=score,
|
||||||
status=ProposalStatus.PROPOSED,
|
status=ProposalStatus.PROPOSED,
|
||||||
dedupe_key=dedupe,
|
dedupe_key=dedupe,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ class Config:
|
|||||||
enable_http_actions: bool = field(
|
enable_http_actions: bool = field(
|
||||||
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_HTTP_ACTIONS", False)
|
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 ---
|
# --- mail ---
|
||||||
imap_host: str = field(
|
imap_host: str = field(
|
||||||
@@ -162,6 +171,10 @@ class Config:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(RuntimeError):
|
||||||
|
"""Raised when mandatory configuration is missing."""
|
||||||
|
|
||||||
|
|
||||||
def load() -> Config:
|
def load() -> Config:
|
||||||
"""Build the process Config snapshot."""
|
"""Build the process Config snapshot."""
|
||||||
return Config()
|
return Config()
|
||||||
|
|||||||
+164
-10
@@ -5,16 +5,26 @@ Status machines
|
|||||||
proposal: proposed -> approved -> running -> done | failed
|
proposal: proposed -> approved -> running -> done | failed
|
||||||
proposed -> rejected
|
proposed -> rejected
|
||||||
mail: (inbound) classified: approval|rejection|question|other|needs_review
|
mail: (inbound) classified: approval|rejection|question|other|needs_review
|
||||||
(outbound draft) none -> drafted -> approved -> sent | skipped
|
(any) draft: none -> drafted -> approved -> sent | skipped
|
||||||
content: draft -> approved -> published
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
from datetime import UTC, datetime
|
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
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +32,11 @@ def utcnow() -> datetime:
|
|||||||
return datetime.now(UTC)
|
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):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -54,7 +69,8 @@ class Proposal(Base):
|
|||||||
title: Mapped[str] = mapped_column(String(512))
|
title: Mapped[str] = mapped_column(String(512))
|
||||||
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
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(
|
status: Mapped[ProposalStatus] = mapped_column(
|
||||||
Enum(ProposalStatus), default=ProposalStatus.PROPOSED, index=True
|
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)
|
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_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- audit -------------------------------------------------------------------
|
# --- audit (hash-chained) ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class AuditEntry(Base):
|
class AuditEntry(Base):
|
||||||
@@ -76,13 +94,21 @@ class AuditEntry(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=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(
|
proposal_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True
|
ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
action: Mapped[str] = mapped_column(String(64)) # approve|reject|execute|classify|...
|
action: Mapped[str] = mapped_column(String(64)) # approve|reject|execute|classify|...
|
||||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
result: Mapped[str] = mapped_column(String(32), default="ok") # ok | error
|
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(
|
def audit(
|
||||||
@@ -93,16 +119,135 @@ def audit(
|
|||||||
detail: dict | None = None,
|
detail: dict | None = None,
|
||||||
result: str = "ok",
|
result: str = "ok",
|
||||||
) -> AuditEntry:
|
) -> AuditEntry:
|
||||||
"""Record one audited action. Every component writes through this helper."""
|
"""Record one audited action, chained to the previous entry.
|
||||||
entry = AuditEntry(
|
|
||||||
|
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,
|
actor=actor,
|
||||||
action=action,
|
action=action,
|
||||||
proposal_id=proposal_id,
|
proposal_id=proposal_id,
|
||||||
detail=detail or {},
|
detail=detail or {},
|
||||||
result=result,
|
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).
|
||||||
|
|
||||||
|
Rows with entry_hash IS NULL are ONLY tolerated BEFORE the first hashed
|
||||||
|
row (the documented legacy prefix from before migration 0002). A NULL
|
||||||
|
hash AFTER the chain has started is tampering — an attacker erasing the
|
||||||
|
hash of an intermediate row to hide an edit — and fails verification at
|
||||||
|
that row's id.
|
||||||
|
"""
|
||||||
|
prev_hash: str | None = None # None = still in legacy prefix
|
||||||
|
chain_started = False
|
||||||
|
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
|
||||||
|
if entry.entry_hash is None:
|
||||||
|
if chain_started:
|
||||||
|
# tampering: a hash was erased in the middle of the chain
|
||||||
|
return False, entry.id
|
||||||
|
continue # legacy prefix row: outside the chain by genesis
|
||||||
|
chain_started = True
|
||||||
|
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 --------------------------------------------------------------------
|
# --- mail --------------------------------------------------------------------
|
||||||
@@ -151,6 +296,10 @@ class MailMessage(Base):
|
|||||||
Enum(DraftStatus), default=DraftStatus.NONE
|
Enum(DraftStatus), default=DraftStatus.NONE
|
||||||
)
|
)
|
||||||
draft_reply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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)
|
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -161,6 +310,7 @@ class ContentStatus(enum.StrEnum):
|
|||||||
DRAFT = "draft"
|
DRAFT = "draft"
|
||||||
APPROVED = "approved"
|
APPROVED = "approved"
|
||||||
PUBLISHED = "published"
|
PUBLISHED = "published"
|
||||||
|
SKIPPED = "skipped" # terminal: disabled channel or attempt budget exhausted
|
||||||
|
|
||||||
|
|
||||||
class ContentItem(Base):
|
class ContentItem(Base):
|
||||||
@@ -179,6 +329,10 @@ class ContentItem(Base):
|
|||||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
post_ref: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
post_ref: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
error: Mapped[str | None] = mapped_column(Text, 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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
def pubkey_from_private_hex(private_key_hex: str) -> str:
|
||||||
|
"""Derive the 64-char x-only public key from a 32-byte hex private key.
|
||||||
|
|
||||||
|
Raises ValueError on malformed input.
|
||||||
|
"""
|
||||||
|
if not private_key_hex:
|
||||||
|
raise ValueError("empty private key")
|
||||||
|
try:
|
||||||
|
from coincurve import PublicKey
|
||||||
|
|
||||||
|
return PublicKey.from_secret(bytes.fromhex(private_key_hex)).format().hex()[2:]
|
||||||
|
except (ImportError, ValueError) as exc:
|
||||||
|
raise ValueError(f"cannot derive pubkey: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class SelfApprovalError(RuntimeError):
|
||||||
|
"""Raised when the configured operator pubkey is the identity of any
|
||||||
|
key held by the system itself (e.g. Cayce nostr identity). The design
|
||||||
|
requires a *separate human* operator key — never an agent key."""
|
||||||
|
|
||||||
|
|
||||||
|
FORBIDDEN_IDENTITIES_ENV = "CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS"
|
||||||
|
|
||||||
|
|
||||||
|
def forbidden_identities(cfg) -> list[str]:
|
||||||
|
"""Pubkeys that may never act as the operator key.
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
1. Any private key loaded from the config (nostr_private_key, or any
|
||||||
|
future agent identity).
|
||||||
|
2. An explicit deny-list in CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS
|
||||||
|
(comma-separated hex pubkeys) for keys held outside this process.
|
||||||
|
"""
|
||||||
|
listed: list[str] = []
|
||||||
|
if cfg.nostr_private_key:
|
||||||
|
try:
|
||||||
|
listed.append(pubkey_from_private_hex(cfg.nostr_private_key))
|
||||||
|
except ValueError:
|
||||||
|
pass # malformed nostr key: _signing() already catches it elsewhere
|
||||||
|
import os
|
||||||
|
|
||||||
|
raw = os.environ.get(FORBIDDEN_IDENTITIES_ENV, "")
|
||||||
|
for tok in raw.split(","):
|
||||||
|
tok = tok.strip().lower()
|
||||||
|
if len(tok) == 64:
|
||||||
|
listed.append(tok)
|
||||||
|
return listed
|
||||||
|
|
||||||
|
|
||||||
|
def validate_no_self_approval(cfg) -> None:
|
||||||
|
"""Refuse to operate if the operator pubkey equals any identity held by
|
||||||
|
the system. Raises SelfApprovalError.
|
||||||
|
|
||||||
|
Empty operator_pubkey is fine here (that's the 'not configured' state
|
||||||
|
which _signing() rejects elsewhere); this check is about *wrong* keys.
|
||||||
|
"""
|
||||||
|
if not cfg.operator_pubkey:
|
||||||
|
return
|
||||||
|
me = cfg.operator_pubkey.strip().lower()
|
||||||
|
for forbidden in forbidden_identities(cfg):
|
||||||
|
if me == forbidden:
|
||||||
|
raise SelfApprovalError(
|
||||||
|
"CAYCE_OPS_OPERATOR_PUBKEY is an agent identity (Cayce or "
|
||||||
|
"another in-process key). The operator key must belong to a "
|
||||||
|
"human and must never reside on this server. Refusing to "
|
||||||
|
"operate. See docs/DECISIONS.md D16."
|
||||||
|
)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# RESTORE.md — Restauración de CayceOps desde backups cifrados
|
||||||
|
|
||||||
|
**Modelo:** la VPS genera backups cifrados (age) en `/var/backups/cayce/YYYY-MM-DD/`;
|
||||||
|
tu Mac los descarga con el usuario `backup-pull` (solo lectura). La clave
|
||||||
|
**privada** de age vive SOLO en tu Mac. Este runbook asume una VPS nueva
|
||||||
|
(Ubuntu + IP apuntada por DNS) y tus backups descifrados a mano.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Descargar y descifrar (en TU Mac — nunca en la VPS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# pull (clave restringida: solo lectura de /var/backups/cayce)
|
||||||
|
rsync -av --partial backup-pull@15.235.3.239:/ ~/backups/cayce/
|
||||||
|
|
||||||
|
# integridad (sin clave privada; manifest lista los artefactos CIFRADOS)
|
||||||
|
cd ~/backups/cayce/2026-XX-XX && shasum -a 256 -c manifest.sha256
|
||||||
|
# (Linux: sha256sum -c manifest.sha256)
|
||||||
|
|
||||||
|
# descifrar (clave privada age del operador — la que NO está en la VPS)
|
||||||
|
age -d -i ~/keys/cayce-backup.txt -o cayce-ops-db.dump cayce-ops-db.dump.age
|
||||||
|
age -d -i ~/keys/cayce-backup.txt -o gitea-dump.zip gitea-dump.zip.age
|
||||||
|
age -d -i ~/keys/cayce-backup.txt -o config.tar.gz config.tar.gz.age
|
||||||
|
```
|
||||||
|
|
||||||
|
Artefactos:
|
||||||
|
| Archivo | Contenido | Formato |
|
||||||
|
|---|---|---|
|
||||||
|
| `cayce-ops-db.dump` | BD de CayceOps | `pg_dump` custom (restaurar con `pg_restore`) |
|
||||||
|
| `gitea-dump.zip` | repos + sqlite + adjuntos + config Gitea | zip de `gitea dump` |
|
||||||
|
| `config.tar.gz` | `caddy/` (Caddyfile+Caddyfile.d), `gitea/` (app.ini + internal_token), `systemd/` (unidades cayce-*), `cayce-ops/dot-env` | tar.gz |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. VPS nueva — base del sistema
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y postgresql age rsync git
|
||||||
|
# Gitea: misma versión mayor que la VPS origen (verSTEP: gitea --version en el viejo
|
||||||
|
# servidor ANTES de apagarlo, o lee la versión del zip gitea-dump: gitea-dump zip
|
||||||
|
# contiene un archivo "app.ini" con la versión en comentarios... anota la versión
|
||||||
|
# manualmente; el binario se baja de https://dl.gitea.com/gitea/)
|
||||||
|
adduser --system --group --home /var/lib/gitea git
|
||||||
|
mkdir -p /etc/gitea /var/lib/gitea /etc/cayce-backup
|
||||||
|
curl -L "https://dl.gitea.com/gitea/<versión>/gitea-<versión>-linux-amd64" -o /usr/local/bin/gitea
|
||||||
|
chmod +x /usr/local/bin/gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Postgres
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl enable --now postgresql
|
||||||
|
sudo -u postgres psql <<SQL
|
||||||
|
CREATE ROLE cayce LOGIN PASSWORD '<password-del-.env-restaurado>';
|
||||||
|
CREATE DATABASE cayce_ops OWNER cayce;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# restaurar el dump (los ficheros descifrados son legibles solo por ti;
|
||||||
|
# cópiales permisos de lectura para postgres o pásalos por stdin)
|
||||||
|
sudo -u postgres pg_restore -d cayce_ops --no-owner --no-privileges \
|
||||||
|
/ruta/cayce-ops-db.dump
|
||||||
|
|
||||||
|
# verificación
|
||||||
|
sudo -u postgres psql -d cayce_ops -tAc \
|
||||||
|
"SELECT count(*) FROM audit_log;" # debe coincidir con la última ancla
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Gitea
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# config restaurada desde config.tar.gz
|
||||||
|
tar -xzf config.tar.gz -C /tmp/restored
|
||||||
|
cp /tmp/restored/gitea/app.ini /etc/gitea/app.ini
|
||||||
|
chown root:git /etc/gitea/app.ini && chmod 660 /etc/gitea/app.ini
|
||||||
|
cp /tmp/restored/gitea/internal_token /etc/gitea/ && chown git:git /etc/gitea/internal_token
|
||||||
|
|
||||||
|
# NOTA sobre ROOT_URL: app.ini restaurado apunta a https://git.cayce-co.xyz/ —
|
||||||
|
# el DNS de ese subdominio debe existir ANTES de iniciar Gitea o las URLs
|
||||||
|
# absolutas (webhooks, notificaciones) quedarán mal.
|
||||||
|
|
||||||
|
# dump de Gitea: contiene data/, repos/ y custom/. Restaurar SOBRE el dump
|
||||||
|
# de Gitea exige la misma versión del binario; descomprimir dentro de WORK_PATH
|
||||||
|
systemctl stop gitea 2>/dev/null || true
|
||||||
|
unzip -o gitea-dump.zip -d /tmp/gitea-restore
|
||||||
|
rsync -a /tmp/gitea-restore/data/ /var/lib/gitea/data/
|
||||||
|
rsync -a /tmp/gitea-restore/repos/ /var/lib/gitea/data/gitea-repositories/ 2>/dev/null || true
|
||||||
|
rsync -a /tmp/gitea-restore/custom/ /var/lib/gitea/custom/ 2>/dev/null || true
|
||||||
|
chown -R git:git /var/lib/gitea
|
||||||
|
sudo -u git /usr/local/bin/gitea migrate -c /etc/gitea/app.ini # ajusta esquema si cambia versión
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Consistencia:** los dumps de Gitea se toman EN CALIENTE (el servicio sigue
|
||||||
|
> corriendo). Riesgo teórico de inconsistencia sqlite/repos si un push ocurre
|
||||||
|
> en el mismo instante. Para un backup garantizado-consistente: `systemctl stop
|
||||||
|
> gitea` antes de `gitea dump` y volver a levantar después (no automatizado a
|
||||||
|
> propósito: no queremos ventanas de downtime diarias).
|
||||||
|
|
||||||
|
## 4. Config del sistema (Caddy, unidades systemd, .env)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Caddy
|
||||||
|
apt-get install -y caddy # o el instalador oficial
|
||||||
|
rsync -a /tmp/restored/caddy/Caddyfile /etc/caddy/Caddyfile
|
||||||
|
rsync -a /tmp/restored/caddy/Caddyfile.d/ /etc/caddy/Caddyfile.d/
|
||||||
|
systemctl enable --now caddy # emitirá certificados si el DNS ya apunta aquí
|
||||||
|
|
||||||
|
# unidades systemd de cayce (DESHABILITADAS por diseño — ver riesgos)
|
||||||
|
rsync -a /tmp/restored/systemd/ /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
# NO habilitar cayce-ops@*.timer sin el operador
|
||||||
|
|
||||||
|
# .env de cayce-ops
|
||||||
|
install -d -m 755 /home/ubuntu
|
||||||
|
rsync -a /tmp/restored/cayce-ops/dot-env /home/ubuntu/cayce-ops/.env
|
||||||
|
chown ubuntu:ubuntu /home/ubuntu/cayce-ops/.env && chmod 660 /home/ubuntu/cayce-ops/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Código del repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u ubuntu git clone http://127.0.0.1:3100/cayce/cayce-ops.git /home/ubuntu/cayce-ops \
|
||||||
|
|| git clone https://git.cayce-co.xyz/cayce/cayce-ops.git /home/ubuntu/cayce-ops
|
||||||
|
cd /home/ubuntu/cayce-ops && uv sync
|
||||||
|
make check # gate verde antes de considerar la restauración completa
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Reinstalar el pipeline de backup en la VPS nueva
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -m 700 -o root -g root deploy/backup/make-backup.sh /usr/local/bin/cayce-make-backup
|
||||||
|
install -m 644 deploy/systemd/cayce-backup.{service,timer} /etc/systemd/system/
|
||||||
|
systemctl daemon-reload && systemctl enable --now cayce-backup.timer
|
||||||
|
# la MISMA clave pública age → tu Mac sigue pudiendo descifrar:
|
||||||
|
echo 'age1<tu-pubkey>' > /etc/cayce-backup/age-recipient.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Checklist de verificación post-restauración
|
||||||
|
|
||||||
|
- [ ] `sha256sum -c manifest.sha256` OK en tu Mac (antes de descifrar)
|
||||||
|
- [ ] `pg_restore --list` del dump sin errores
|
||||||
|
- [ ] `verify_audit_chain(sess)` → `(True, None)` contra la BD restaurada
|
||||||
|
- [ ] Gitea responde por TLS en `git.cayce-co.xyz` y `git rev-parse main` da el commit esperado
|
||||||
|
- [ ] `make check` verde en el repo restaurado
|
||||||
|
- [ ] Primer backup nuevo generado y verificado desde la Mac
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# backup-pull — usuario de solo lectura para el pull desde la Mac
|
||||||
|
|
||||||
|
La VPS genera los backups cifrados en `/var/backups/cayce/YYYY-MM-DD/`.
|
||||||
|
La Mac los descarga por rsync con una clave SSH **restringida**. La VPS
|
||||||
|
nunca tiene credenciales para escribir ni borrar nada fuera de sí misma.
|
||||||
|
|
||||||
|
## Estado instalado en la VPS
|
||||||
|
|
||||||
|
- Usuario de sistema `backup-pull` (uid 1001): **sin sudo**, password
|
||||||
|
bloqueado, home `/home/backup-pull`, shell `/bin/sh` (necesario para que
|
||||||
|
sshd ejecute el forced command; la restricción real la pone la clave, no
|
||||||
|
el shell).
|
||||||
|
- `/home/backup-pull/.ssh/authorized_keys` (600, backup-pull:backup-pull) —
|
||||||
|
**vacío** hasta que el operador añada su clave pública.
|
||||||
|
- `/var/backups/cayce` → `root:backup-pull`, `750`; artefactos `640`.
|
||||||
|
|
||||||
|
## Formato de la línea authorized_keys (una sola línea)
|
||||||
|
|
||||||
|
restrict,command="/usr/bin/rrsync -ro /var/backups/cayce" ssh-ed25519 AAAA... comentario
|
||||||
|
|
||||||
|
`restrict` = no-pty, no-port-forwarding, no-agent-forwarding, no-X11-forwarding
|
||||||
|
(y sin sftp). `rrsync -ro` = solo rsync de LECTURA, confinado a
|
||||||
|
`/var/backups/cayce`. Añadirla con:
|
||||||
|
|
||||||
|
echo 'restrict,command="/usr/bin/rrsync -ro /var/backups/cayce" ssh-ed25519 AAAA... cayce-mac-pull' \
|
||||||
|
| sudo tee -a /home/backup-pull/.ssh/authorized_keys
|
||||||
|
|
||||||
|
## Pull desde la Mac
|
||||||
|
|
||||||
|
rsync -av --partial backup-pull@15.235.3.239:/ ~/backups/cayce/
|
||||||
|
|
||||||
|
## Verificación realizada (2026-10-28, con clave desechada después)
|
||||||
|
|
||||||
|
| Prueba | Resultado |
|
||||||
|
|---|---|
|
||||||
|
| Shell interactivo | ✗ `rrsync error: Not invoked via sshd` |
|
||||||
|
| Comando arbitrario (`cat /etc/shadow`) | ✗ `SSH_ORIGINAL_COMMAND does not run rsync` |
|
||||||
|
| rsync de `/etc/` | ✗ `invalid rsync-command syntax or options` (fuera del root permitido) |
|
||||||
|
| scp/sftp | ✗ (restrict) |
|
||||||
|
| **rsync pull de `/var/backups/cayce/`** | ✓ funciona |
|
||||||
|
| rsync write (upload) | ✗ protocolo cortado, nada escrito |
|
||||||
|
|
||||||
|
La clave usada para las pruebas se **borró**; `authorized_keys` quedó vacío.
|
||||||
Executable
+119
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# cayce-backup — encrypted daily backup of CayceOps + Gitea + config.
|
||||||
|
#
|
||||||
|
# Design contract (Tanda B):
|
||||||
|
# - EVERY artifact is encrypted with age BEFORE it leaves the tmpdir.
|
||||||
|
# No plaintext is ever written outside the tmpdir, which is removed
|
||||||
|
# by a trap even on failure.
|
||||||
|
# - The age recipient lives in /etc/cayce-backup/age-recipient.pub
|
||||||
|
# (public key only). The PRIVATE key never touches this server:
|
||||||
|
# decryption happens on the operator's machine after a pull.
|
||||||
|
# - Any step failure => non-zero exit => journald entry (run via systemd).
|
||||||
|
# - Retention: 7 daily directories under /var/backups/cayce/.
|
||||||
|
#
|
||||||
|
# The restore procedure lives in deploy/backup/RESTORE.md.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DATE="$(date -u +%F)"
|
||||||
|
BACKUP_ROOT="/var/backups/cayce"
|
||||||
|
DEST="$BACKUP_ROOT/$DATE"
|
||||||
|
RECIPIENT_FILE="/etc/cayce-backup/age-recipient.pub"
|
||||||
|
CAYCE_DIR="/home/ubuntu/cayce-ops"
|
||||||
|
ENV_FILE="$CAYCE_DIR/.env"
|
||||||
|
RETENTION_DAYS=7
|
||||||
|
|
||||||
|
log() { echo "[cayce-backup] $(date -u +%FT%TZ) $*"; }
|
||||||
|
die() { echo "[cayce-backup] FATAL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# --- preconditions -------------------------------------------------------------
|
||||||
|
[[ $EUID -eq 0 ]] || die "must run as root (systemd)"
|
||||||
|
command -v age >/dev/null || die "age not installed"
|
||||||
|
[[ -r "$RECIPIENT_FILE" ]] || die "age recipient not configured: create $RECIPIENT_FILE (public key only)"
|
||||||
|
RECIPIENT="$(tr -d "[:space:]" < "$RECIPIENT_FILE")"
|
||||||
|
[[ "$RECIPIENT" == age1* ]] || die "$RECIPIENT_FILE does not look like an age1... public key"
|
||||||
|
|
||||||
|
# --- workspace: everything plaintext lives here and dies here ------------------
|
||||||
|
TMP="$(mktemp -d /tmp/cayce-backup.XXXXXX)"
|
||||||
|
# 711: root-only listing, but the git user must be able to traverse into
|
||||||
|
# its own 700 subdir for gitea dump; plaintext root files stay unreadable
|
||||||
|
chmod 711 "$TMP"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMP"
|
||||||
|
if [[ "${FAILED:-0}" == "1" ]]; then
|
||||||
|
rm -rf "$DEST" # never leave a partial day directory
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
STAGE="$TMP/stage" # encrypted artifacts land here before install
|
||||||
|
install -d -m 700 "$STAGE"
|
||||||
|
|
||||||
|
encrypt() { # encrypt <plaintext-path> <name.age>
|
||||||
|
age -r "$RECIPIENT" -o "$STAGE/$2" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 1. Postgres dump (cayce-ops DB) --------------------------------------------
|
||||||
|
DB_URL="${CAYCE_OPS_DATABASE_URL:-}"
|
||||||
|
if [[ -z "$DB_URL" && -r "$ENV_FILE" ]]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
DB_URL="$(grep -E "^CAYCE_OPS_DATABASE_URL=" "$ENV_FILE" | head -1 | cut -d= -f2-)"
|
||||||
|
fi
|
||||||
|
[[ -n "$DB_URL" ]] || die "no CAYCE_OPS_DATABASE_URL in env or $ENV_FILE"
|
||||||
|
PG_URL="$(printf "%s" "$DB_URL" | sed -E "s#postgresql\+[a-z]+://#postgresql://#")"
|
||||||
|
log "dumping database from ${PG_URL%%@*@}***@..."
|
||||||
|
pg_dump "$PG_URL" --format=custom --file "$TMP/cayce-ops-db.dump"
|
||||||
|
encrypt "$TMP/cayce-ops-db.dump" "cayce-ops-db.dump.age"
|
||||||
|
log "database dump: $(du -h "$TMP/cayce-ops-db.dump" | cut -f1) plaintext, encrypted ok"
|
||||||
|
|
||||||
|
# --- 2. Gitea dump (repos + sqlite + attachments) -------------------------------
|
||||||
|
GITEA_BIN="$(command -v gitea || echo /usr/local/bin/gitea)"
|
||||||
|
[[ -x "$GITEA_BIN" ]] || die "gitea binary not found"
|
||||||
|
install -d -o git -g git -m 700 "$TMP/gitea-work"
|
||||||
|
log "running gitea dump (live; see RESTORE.md for consistency notes)..."
|
||||||
|
sudo -u git "$GITEA_BIN" dump -c /etc/gitea/app.ini \
|
||||||
|
--file "$TMP/gitea-work/gitea-dump.zip" --tempdir "$TMP/gitea-work" --quiet
|
||||||
|
encrypt "$TMP/gitea-work/gitea-dump.zip" "gitea-dump.zip.age"
|
||||||
|
log "gitea dump: $(du -h "$TMP/gitea-work/gitea-dump.zip" | cut -f1) plaintext, encrypted ok"
|
||||||
|
|
||||||
|
# --- 3. Config tar (Caddy, Gitea app.ini + internal_token, units, .env) ----------
|
||||||
|
CONF="$TMP/config"
|
||||||
|
install -d -m 700 "$CONF"
|
||||||
|
mkdir -p "$CONF/caddy" "$CONF/gitea" "$CONF/systemd" "$CONF/cayce-ops"
|
||||||
|
cp -a /etc/caddy/Caddyfile "$CONF/caddy/" 2>/dev/null || true
|
||||||
|
cp -a /etc/caddy/Caddyfile.d "$CONF/caddy/" 2>/dev/null || true
|
||||||
|
cp -a /etc/gitea/app.ini "$CONF/gitea/" 2>/dev/null || true
|
||||||
|
cp -a /etc/gitea/internal_token "$CONF/gitea/" 2>/dev/null || true
|
||||||
|
find /etc/systemd/system -maxdepth 1 -name "cayce-*" \
|
||||||
|
-exec cp -a {} "$CONF/systemd/" \; 2>/dev/null || true
|
||||||
|
[[ -r "$ENV_FILE" ]] && cp -a "$ENV_FILE" "$CONF/cayce-ops/dot-env" \
|
||||||
|
|| log "WARNING: $ENV_FILE not present — config backup continues without it"
|
||||||
|
# at least Caddy or Gitea config must exist, else the tar is useless
|
||||||
|
[[ -e "$CONF/caddy/Caddyfile" || -e "$CONF/gitea/app.ini" ]] \
|
||||||
|
|| die "no config sources found (Caddyfile/app.ini missing)"
|
||||||
|
tar -C "$CONF" -czf "$TMP/config.tar.gz" .
|
||||||
|
encrypt "$TMP/config.tar.gz" "config.tar.gz.age"
|
||||||
|
log "config tar: $(du -h "$TMP/config.tar.gz" | cut -f1) plaintext, encrypted ok"
|
||||||
|
|
||||||
|
# --- 4. install + manifest -------------------------------------------------------
|
||||||
|
install -d -m 750 -o root -g backup-pull "$BACKUP_ROOT" 2>/dev/null \
|
||||||
|
|| install -d -m 700 "$BACKUP_ROOT" # backup-pull group may not exist yet
|
||||||
|
install -d -m 750 -o root -g backup-pull "$DEST" 2>/dev/null \
|
||||||
|
|| install -d -m 700 "$DEST"
|
||||||
|
install -m 640 -o root -g backup-pull "$STAGE"/*.age "$DEST/" 2>/dev/null \
|
||||||
|
|| install -m 600 "$STAGE"/*.age "$DEST/"
|
||||||
|
( cd "$DEST" && sha256sum ./*.age > "$TMP/manifest.sha256" )
|
||||||
|
install -m 640 -o root -g backup-pull "$TMP/manifest.sha256" "$DEST/manifest.sha256" 2>/dev/null \
|
||||||
|
|| install -m 600 "$TMP/manifest.sha256" "$DEST/manifest.sha256"
|
||||||
|
log "manifest written: $(wc -l < "$DEST/manifest.sha256") artifacts"
|
||||||
|
|
||||||
|
# --- 5. retention -----------------------------------------------------------------
|
||||||
|
PRUNED=0
|
||||||
|
while IFS= read -r old; do
|
||||||
|
rm -rf "$old"
|
||||||
|
PRUNED=$((PRUNED + 1))
|
||||||
|
log "retention: removed $old"
|
||||||
|
done < <(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name "2*-*-*" -mtime +"$RETENTION_DAYS")
|
||||||
|
log "retention: kept last $RETENTION_DAYS days (pruned $PRUNED)"
|
||||||
|
|
||||||
|
log "BACKUP OK → $DEST ($(du -sh "$DEST" | cut -f1))"
|
||||||
@@ -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 -u cayce cayceops db upgrade # alembic to head
|
||||||
sudo cp deploy/systemd/cayce-ops@.service deploy/systemd/cayce-ops@.timer /etc/systemd/system/
|
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):
|
# cadence per daemon (edit OnCalendar in each timer copy):
|
||||||
# scanner hourly executor */10 min maild */5 min
|
# scanner hourly executor */10 min maild */5 min
|
||||||
# publisher */5 min monitor */1 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
|
for d in scanner executor maild publisher monitor; do
|
||||||
sudo cp deploy/systemd/cayce-ops@.timer /etc/systemd/system/cayce-ops@$d.timer
|
sudo cp deploy/systemd/cayce-ops@.timer /etc/systemd/system/cayce-ops@$d.timer
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Cayce — encrypted daily backup (CayceOps DB + Gitea + config)
|
||||||
|
After=network-online.target postgresql.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/cayce-make-backup
|
||||||
|
# hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=/var/backups/cayce /tmp
|
||||||
|
ProtectSystem=strict
|
||||||
|
PrivateTmp=false
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Cayce — daily backup schedule (03:15 UTC)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:15:00 UTC
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=300
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -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_PASSWORD: localdev-only-not-a-secret
|
||||||
POSTGRES_DB: cayce_ops
|
POSTGRES_DB: cayce_ops
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "127.0.0.1:5432:5432" # loopback only — Docker bypasses host firewalls
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -69,3 +69,78 @@ An action plugin is a Python function registered in
|
|||||||
`http_post` exists but is inert unless `CAYCE_OPS_ENABLE_HTTP_ACTIONS=true`.
|
`http_post` exists but is inert unless `CAYCE_OPS_ENABLE_HTTP_ACTIONS=true`.
|
||||||
*Cost:* no sandboxing of plugins — plugins are repo code, reviewed like
|
*Cost:* no sandboxing of plugins — plugins are repo code, reviewed like
|
||||||
everything else.
|
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.
|
||||||
|
|
||||||
|
## D16 — La clave del operador nunca es una identidad de agente
|
||||||
|
|
||||||
|
**Fecha:** 2026-10-28
|
||||||
|
|
||||||
|
**Contexto:** se descubrió que `CAYCE_OPS_OPERATOR_PUBKEY` estaba configurada
|
||||||
|
con la pubkey de Cayce — es decir, Cayce se aprobaba a sí misma. La firma
|
||||||
|
BIP-340 era técnicamente válida pero semánticamente nula: "aprobación humana"
|
||||||
|
firmada por el agente no es aprobación humana.
|
||||||
|
|
||||||
|
**Regla:** los daemons y el CLI se niegan a operar si la pubkey del operador
|
||||||
|
coincide con:
|
||||||
|
1. La pubkey derivada de cualquier clave privada cargada en la config
|
||||||
|
(`CAYCE_OPS_NOSTR_PRIVATE_KEY` u otra identidad de agente futura), o
|
||||||
|
2. Cualquier pubkey en `CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS` (deny-list
|
||||||
|
explícita, para claves fuera de este proceso).
|
||||||
|
|
||||||
|
**Implementación:** `cayceops.signing.validate_no_self_approval()`, llamada
|
||||||
|
desde `_signing()` en executor/maild/publisher. Lanza `SelfApprovalError`.
|
||||||
|
|
||||||
|
**La firma del operador se produce FUERA de la VPS.** El CLI `approve-*`
|
||||||
|
acepta la firma como parámetro (ya lo hace); nunca la genera él. Mientras
|
||||||
|
no exista el signer externo, `CAYCE_OPS_OPERATOR_PUBKEY` debe permanecer
|
||||||
|
vacía — los daemons se niegan a operar y eso es lo correcto.
|
||||||
|
|
||||||
|
**La nsec de Cayce NO se toca:** sigue siendo la identidad del agente para
|
||||||
|
publicar y anclar. Lo que se prohíbe es usarla como clave de operador.
|
||||||
|
|||||||
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
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
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.
|
# Config must see the test DB before cayceops.config/db get imported.
|
||||||
os.environ.setdefault("CAYCE_OPS_DATABASE_URL", "sqlite:///:memory:")
|
os.environ.setdefault("CAYCE_OPS_DATABASE_URL", "sqlite:///:memory:")
|
||||||
@@ -54,6 +60,61 @@ def sess(db_engine):
|
|||||||
s.close()
|
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()
|
@pytest.fixture()
|
||||||
def cfg(monkeypatch):
|
def cfg(monkeypatch):
|
||||||
"""A Config snapshot with test defaults (no network, no autonomy)."""
|
"""A Config snapshot with test defaults (no network, no autonomy)."""
|
||||||
@@ -66,6 +127,10 @@ def cfg(monkeypatch):
|
|||||||
"CAYCE_OPS_ENABLE_INSTAGRAM": "false",
|
"CAYCE_OPS_ENABLE_INSTAGRAM": "false",
|
||||||
"CAYCE_OPS_NOSTR_RELAYS": "",
|
"CAYCE_OPS_NOSTR_RELAYS": "",
|
||||||
"CAYCE_OPS_MEDIA_DIR": "media",
|
"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):
|
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='operator', "
|
||||||
|
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='operator', approved_hash='"
|
||||||
|
+ "a" * 64
|
||||||
|
+ f"', operator_signature='{sig}' WHERE id={mail_id}"
|
||||||
|
)
|
||||||
|
with _connect_as("cayce_maild") as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE mail_messages SET draft_status='SENT' WHERE id={mail_id}"
|
||||||
|
)
|
||||||
|
maild.close()
|
||||||
|
|
||||||
|
# --- publisher role: plans content, cannot approve ---
|
||||||
|
with _connect_as("cayce_publisher") as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO content_items (theme, channel, caption, status,
|
||||||
|
attempts, created_at)
|
||||||
|
VALUES ('atm-fees', 'nostr', 'caption', 'DRAFT', 0, now())
|
||||||
|
RETURNING id"""
|
||||||
|
)
|
||||||
|
item_id = cur.fetchone()[0]
|
||||||
|
publisher = _connect_as("cayce_publisher")
|
||||||
|
_must_fail(
|
||||||
|
publisher,
|
||||||
|
f"UPDATE content_items SET status='APPROVED' WHERE id={item_id}",
|
||||||
|
because="only cayce_approver may set content status=approved",
|
||||||
|
)
|
||||||
|
# approver approves; publisher publishes
|
||||||
|
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE content_items SET status='APPROVED', approved_hash='"
|
||||||
|
+ "b" * 64
|
||||||
|
+ f"', operator_signature='{sig}' WHERE id={item_id}"
|
||||||
|
)
|
||||||
|
with _connect_as("cayce_publisher") as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE content_items SET status='PUBLISHED', published_at=now() "
|
||||||
|
f"WHERE id={item_id}"
|
||||||
|
)
|
||||||
|
publisher.close()
|
||||||
|
|
||||||
|
# --- monitor role: read-only + audit anchors ---
|
||||||
|
monitor = _connect_as("cayce_monitor")
|
||||||
|
with monitor.cursor() as cur:
|
||||||
|
cur.execute("SELECT count(*) FROM proposals")
|
||||||
|
cur.fetchone()
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO audit_log (ts, actor, action, detail, result) "
|
||||||
|
"VALUES (now(), 'daemon:monitor', 'anchor', '{}', 'ok')"
|
||||||
|
)
|
||||||
|
# monitor CAN book its runs, but cannot touch proposals
|
||||||
|
with monitor.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO daemon_runs (daemon, started_at, status, detail) "
|
||||||
|
"VALUES ('monitor', now(), 'ok', '{}')"
|
||||||
|
)
|
||||||
|
_must_fail(
|
||||||
|
monitor,
|
||||||
|
"INSERT INTO proposals (source, kind, title, detail, match_score, status, "
|
||||||
|
"attempts, created_at, updated_at) "
|
||||||
|
"VALUES ('SCANNER', 'other', 'x', '{}', 0, 'PROPOSED', 0, now(), now())",
|
||||||
|
because="permission denied",
|
||||||
|
)
|
||||||
|
monitor.close()
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Round-3 hardening tests: append-only audit log + strict legacy-prefix rule
|
||||||
|
+ anchor cross-verification (issue: operator key WAS the agent key; the
|
||||||
|
audit trail must now be tamper-evident against the DB owner itself)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from cayceops.models import AuditEntry, audit, verify_audit_chain
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(sess, n: int = 3) -> list[int]:
|
||||||
|
ids = []
|
||||||
|
for i in range(n):
|
||||||
|
audit(sess, actor=f"daemon:{i}", action=f"act{i}", detail={"i": i})
|
||||||
|
sess.flush()
|
||||||
|
ids.append(sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first().id)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
# --- 1. legacy-prefix rule -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_before_genesis_still_ok(sess):
|
||||||
|
"""Documented legacy prefix: NULL hashes before the first hashed row."""
|
||||||
|
sess.add(AuditEntry(actor="legacy", action="old", detail={}, ts=__import__(
|
||||||
|
"datetime").datetime.now(__import__("datetime").UTC)))
|
||||||
|
sess.flush()
|
||||||
|
_seed(sess, 2)
|
||||||
|
sess.flush()
|
||||||
|
ok, broken = verify_audit_chain(sess)
|
||||||
|
assert ok and broken is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_in_middle_fails(sess):
|
||||||
|
"""THE FIX: erasing a hash mid-chain is tampering and must be reported."""
|
||||||
|
ids = _seed(sess, 3)
|
||||||
|
middle = ids[1]
|
||||||
|
sess.execute(
|
||||||
|
text("UPDATE audit_log SET entry_hash = NULL WHERE id = :i"), {"i": middle}
|
||||||
|
)
|
||||||
|
sess.flush()
|
||||||
|
ok, broken = verify_audit_chain(sess)
|
||||||
|
assert not ok
|
||||||
|
assert broken == middle
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_after_chain_started_reports_the_row(sess):
|
||||||
|
ids = _seed(sess, 4)
|
||||||
|
sess.execute(
|
||||||
|
text("UPDATE audit_log SET entry_hash = NULL WHERE id = :i"), {"i": ids[3]}
|
||||||
|
)
|
||||||
|
sess.flush()
|
||||||
|
ok, broken = verify_audit_chain(sess)
|
||||||
|
assert not ok and broken == ids[3]
|
||||||
|
|
||||||
|
|
||||||
|
# --- 2/3. append-only enforcement (migration 0004) ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_0004(sess):
|
||||||
|
import os
|
||||||
|
|
||||||
|
path = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"alembic",
|
||||||
|
"versions",
|
||||||
|
"0004_audit_append_only.py",
|
||||||
|
)
|
||||||
|
spec = {}
|
||||||
|
with open(path) as fh:
|
||||||
|
exec(compile(fh.read(), path, "exec"), spec)
|
||||||
|
# reuse the sqlite trigger DDL directly (dialect-aware inside the module)
|
||||||
|
for ddl in spec["SQLITE_TRIGGERS"]:
|
||||||
|
sess.execute(text(ddl))
|
||||||
|
sess.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_rejected_after_migration(sess):
|
||||||
|
_seed(sess, 1)
|
||||||
|
_apply_0004(sess)
|
||||||
|
with pytest.raises(Exception, match="append-only"):
|
||||||
|
sess.execute(text("UPDATE audit_log SET actor = 'evil'"))
|
||||||
|
sess.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_rejected_after_migration(sess):
|
||||||
|
_seed(sess, 1)
|
||||||
|
_apply_0004(sess)
|
||||||
|
with pytest.raises(Exception, match="append-only"):
|
||||||
|
sess.execute(text("DELETE FROM audit_log"))
|
||||||
|
sess.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_still_allowed_after_migration(sess):
|
||||||
|
_seed(sess, 1)
|
||||||
|
_apply_0004(sess)
|
||||||
|
audit(sess, actor="daemon:x", action="still-works", detail={})
|
||||||
|
sess.flush()
|
||||||
|
ok, broken = verify_audit_chain(sess)
|
||||||
|
assert ok and broken is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- 4. anchor cross-verification ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_anchors_reported_explicitly(sess):
|
||||||
|
from cayceops.components.monitor.monitor import verify_chain_against_anchors
|
||||||
|
|
||||||
|
_seed(sess, 2)
|
||||||
|
report = verify_chain_against_anchors(sess)
|
||||||
|
assert report["status"] == "no_anchors"
|
||||||
|
assert "never been anchored" in report["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_only_when_no_fetcher(sess):
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from cayceops.components.monitor.monitor import verify_chain_against_anchors
|
||||||
|
|
||||||
|
_seed(sess, 2)
|
||||||
|
last = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||||
|
sess.add(
|
||||||
|
AuditEntry(
|
||||||
|
actor="daemon:monitor",
|
||||||
|
action="anchor",
|
||||||
|
detail={
|
||||||
|
"event_id": "deadbeef",
|
||||||
|
"last_hash": last.entry_hash,
|
||||||
|
"last_entry": last.id,
|
||||||
|
},
|
||||||
|
ts=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sess.flush()
|
||||||
|
report = verify_chain_against_anchors(sess)
|
||||||
|
assert report["status"] == "local_only"
|
||||||
|
assert "LOCAL record" in report["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_mismatch_detected(sess):
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from cayceops.components.monitor.monitor import verify_chain_against_anchors
|
||||||
|
|
||||||
|
_seed(sess, 2)
|
||||||
|
last = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||||
|
sess.add(
|
||||||
|
AuditEntry(
|
||||||
|
actor="daemon:monitor",
|
||||||
|
action="anchor",
|
||||||
|
detail={"event_id": "beef", "last_hash": last.entry_hash, "last_entry": last.id},
|
||||||
|
ts=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sess.flush()
|
||||||
|
|
||||||
|
def foreign_fetcher(deployment):
|
||||||
|
# a note attesting some OTHER chain entirely
|
||||||
|
return ["cayce-ops audit anchor deployment=itest last_hash=" + "aa" * 32]
|
||||||
|
|
||||||
|
report = verify_chain_against_anchors(sess, relay_fetcher=foreign_fetcher)
|
||||||
|
assert report["status"] == "mismatch"
|
||||||
|
assert "does not attest" in report["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_match_ok(sess):
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from cayceops.components.monitor.monitor import verify_chain_against_anchors
|
||||||
|
|
||||||
|
_seed(sess, 2)
|
||||||
|
last = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||||
|
sess.add(
|
||||||
|
AuditEntry(
|
||||||
|
actor="daemon:monitor",
|
||||||
|
action="anchor",
|
||||||
|
detail={"event_id": "beef", "last_hash": last.entry_hash, "last_entry": last.id},
|
||||||
|
ts=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sess.flush()
|
||||||
|
|
||||||
|
def honest_fetcher(deployment):
|
||||||
|
return [f"cayce-ops audit anchor deployment={deployment} last_hash={last.entry_hash}"]
|
||||||
|
|
||||||
|
report = verify_chain_against_anchors(sess, relay_fetcher=honest_fetcher)
|
||||||
|
assert report["status"] == "ok"
|
||||||
+143
-34
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,38 +17,49 @@ def _proposal(kind="content_gap", title="Test item", **kw):
|
|||||||
return Proposal(kind=kind, title=title, **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()
|
p = _proposal()
|
||||||
sess.add(p)
|
sess.add(p)
|
||||||
sess.flush()
|
sess.flush()
|
||||||
|
|
||||||
assert executor.approve(p.id, "gustavo", cfg(), sess) == 0
|
assert (
|
||||||
|
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||||
|
)
|
||||||
# second approve must fail (state is now approved)
|
# second approve must fail (state is now approved)
|
||||||
assert executor.approve(p.id, "gustavo", cfg(), sess) == 1
|
assert (
|
||||||
assert p.approved_by == "gustavo"
|
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 1
|
||||||
assert p.status == ProposalStatus.APPROVED
|
)
|
||||||
|
assert p.approved_by == "operator"
|
||||||
|
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):
|
def test_reject_records_actor(cfg, sess):
|
||||||
p = _proposal()
|
p = _proposal()
|
||||||
sess.add(p)
|
sess.add(p)
|
||||||
sess.flush()
|
sess.flush()
|
||||||
assert executor.reject(p.id, "gustavo", cfg(), sess) == 0
|
assert executor.reject(p.id, "operator", 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")
|
approved = _proposal(title="Approved item")
|
||||||
proposed = _proposal(title="Proposed item")
|
proposed = _proposal(title="Proposed item")
|
||||||
sess.add_all([approved, proposed])
|
sess.add_all([approved, proposed])
|
||||||
sess.flush()
|
sess.flush()
|
||||||
executor.approve(approved.id, "gustavo", cfg(), sess)
|
assert (
|
||||||
|
executor.approve(
|
||||||
|
approved.id, "operator", cfg(), sess, signature=signer.proposal(approved)
|
||||||
|
)
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
config = cfg() # AUTONOMOUS_MODE=false
|
config = cfg() # AUTONOMOUS_MODE=false
|
||||||
assert executor.run(config, sess) == 0
|
assert executor.run(config, sess) == 0
|
||||||
|
|
||||||
assert approved.status == ProposalStatus.DONE
|
assert approved.status is ProposalStatus.DONE
|
||||||
assert proposed.status == ProposalStatus.PROPOSED # untouched
|
assert proposed.status is ProposalStatus.PROPOSED # untouched
|
||||||
|
|
||||||
acts = [a.action for a in sess.query(AuditEntry).order_by(AuditEntry.id).all()]
|
acts = [a.action for a in sess.query(AuditEntry).order_by(AuditEntry.id).all()]
|
||||||
assert "execute_start" in acts and "execute_done" in acts
|
assert "execute_start" in acts and "execute_done" in acts
|
||||||
@@ -56,36 +72,127 @@ def test_autonomy_off_by_default(cfg, sess):
|
|||||||
sess.flush()
|
sess.flush()
|
||||||
config = cfg() # autonomous_mode False even with kinds configured
|
config = cfg() # autonomous_mode False even with kinds configured
|
||||||
executor._auto_approve(config, sess)
|
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):
|
def test_autonomy_cannot_approve_anymore(cfg, sess):
|
||||||
p1 = _proposal(kind="content_gap", title="auto ok")
|
"""Round-2 1a: with mandatory signatures there is NO non-human approval
|
||||||
p2 = _proposal(kind="content_gap", title="auto over cap")
|
path. AUTONOMOUS_MODE no longer approves anything (D13)."""
|
||||||
p3 = _proposal(kind="affiliate", title="kind not allowed")
|
low = _proposal(kind="content_gap", title="barely relevant")
|
||||||
sess.add_all([p1, p2, p3])
|
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()
|
sess.flush()
|
||||||
|
|
||||||
config = cfg(
|
config = cfg(
|
||||||
CAYCE_OPS_AUTONOMOUS_MODE="true",
|
CAYCE_OPS_AUTONOMOUS_MODE="true",
|
||||||
CAYCE_OPS_AUTONOMOUS_KINDS="content_gap",
|
CAYCE_OPS_AUTONOMOUS_KINDS="content_gap",
|
||||||
CAYCE_OPS_AUTONOMOUS_DAILY_CAP="1",
|
CAYCE_OPS_AUTONOMOUS_DAILY_CAP="1",
|
||||||
)
|
)
|
||||||
approved = executor._auto_approve(config, sess)
|
assert executor._auto_approve(config, sess) == 0
|
||||||
assert approved == 1
|
assert low.status is ProposalStatus.PROPOSED
|
||||||
assert p1.status == ProposalStatus.APPROVED
|
assert high.status is ProposalStatus.PROPOSED
|
||||||
assert p2.status == ProposalStatus.PROPOSED # cap reached
|
entry = sess.query(AuditEntry).filter_by(action="autonomy_disabled").one()
|
||||||
assert p3.status == ProposalStatus.PROPOSED # kind not in policy
|
assert entry.result == "error"
|
||||||
|
|
||||||
actor = sess.query(AuditEntry).filter_by(action="approve").one().actor
|
|
||||||
assert actor == "auto:policy"
|
|
||||||
|
|
||||||
|
|
||||||
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, "operator", 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, "operator", 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, "operator", cfg(), sess, signature=other_content) == 2
|
||||||
|
# valid signature -> approved
|
||||||
|
assert (
|
||||||
|
executor.approve(p.id, "operator", 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, "operator", 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, "operator", 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")
|
p = _proposal(kind="affiliate", title="Will fail")
|
||||||
sess.add(p)
|
sess.add(p)
|
||||||
sess.flush()
|
sess.flush()
|
||||||
executor.approve(p.id, "gustavo", cfg(), sess)
|
assert (
|
||||||
|
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||||
|
)
|
||||||
|
|
||||||
calls = {"n": 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")
|
config = cfg(CAYCE_OPS_MAX_ATTEMPTS="2")
|
||||||
|
|
||||||
executor.run(config, sess) # attempt 1 -> back to approved for retry
|
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
|
assert p.attempts == 1
|
||||||
|
|
||||||
executor.run(config, sess) # attempt 2 -> failed for good
|
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 p.attempts == 2
|
||||||
assert calls["n"] == 2
|
assert calls["n"] == 2
|
||||||
|
|
||||||
@@ -120,14 +227,16 @@ def test_http_post_blocked_when_disabled(cfg, sess):
|
|||||||
plugins.http_post(p, config, 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")
|
p = _proposal(kind="brand_new_kind", title="No plugin yet")
|
||||||
sess.add(p)
|
sess.add(p)
|
||||||
sess.flush()
|
sess.flush()
|
||||||
executor.approve(p.id, "gustavo", cfg(), sess)
|
assert (
|
||||||
|
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||||
|
)
|
||||||
|
|
||||||
assert executor.run(cfg(), sess) == 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()
|
done = sess.query(AuditEntry).filter_by(action="execute_done").one()
|
||||||
assert done.detail["outcome"]["plugin_fallback"] is True
|
assert done.detail["outcome"]["plugin_fallback"] is True
|
||||||
|
|||||||
+410
-36
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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 classify as clf
|
||||||
from cayceops.components.maild import maild
|
from cayceops.components.maild import maild
|
||||||
from cayceops.models import (
|
from cayceops.models import (
|
||||||
@@ -14,10 +19,23 @@ from cayceops.models import (
|
|||||||
ProposalStatus,
|
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>"
|
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 = [
|
lines = [
|
||||||
"From: Partner Desk <partners@acme.example.org>",
|
"From: Partner Desk <partners@acme.example.org>",
|
||||||
"To: agent@cayce-co.xyz",
|
"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()
|
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:
|
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.add(p)
|
||||||
sess.flush()
|
sess.flush()
|
||||||
sess.add(
|
mail_id = maild._compose(
|
||||||
MailMessage(
|
p.id, "partners@acme.example.org", "[cayce] partnership inquiry", "Hello!", sess
|
||||||
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,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
assert (
|
||||||
|
maild._approve_draft(mail_id, "operator", 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()
|
sess.flush()
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _fetcher(raws: list[bytes]):
|
||||||
|
return lambda cfg: [(bytes(str(i), "ascii"), raw) for i, raw in enumerate(raws)]
|
||||||
|
|
||||||
|
|
||||||
# --- classifier ---------------------------------------------------------------
|
# --- classifier ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_rules_classification(cfg):
|
def test_rules_classification(cfg):
|
||||||
config = cfg()
|
config = cfg()
|
||||||
assert clf.classify("Your application was approved, welcome to the program", config)[0] is MailClassification.APPROVAL
|
assert (
|
||||||
assert clf.classify("Unfortunately we cannot accept new partners", config)[0] is MailClassification.REJECTION
|
clf.classify("Your application was approved, welcome to the program", config)[0]
|
||||||
assert clf.classify("Could you provide your traffic numbers?", config)[0] is MailClassification.QUESTION
|
is MailClassification.APPROVAL
|
||||||
kind, confidence = clf.classify("grocery list", config)
|
)
|
||||||
|
# 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
|
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):
|
def test_low_confidence_becomes_needs_review(cfg):
|
||||||
config = cfg(CAYCE_OPS_LLM_REVIEW_THRESHOLD="0.95")
|
config = cfg(CAYCE_OPS_LLM_REVIEW_THRESHOLD="0.95")
|
||||||
kind, confidence = clf.classify("Your application was approved, welcome", config)
|
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):
|
def test_poll_threads_approval_and_drafts_reply(cfg, sess):
|
||||||
p = _proposal_with_sent_draft(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 stats["processed"] == 1 and stats["drafted"] == 1
|
||||||
|
assert len(nums) == 1
|
||||||
|
|
||||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||||
assert inbound.classification is MailClassification.APPROVAL
|
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):
|
def test_poll_idempotent_on_message_id(cfg, sess):
|
||||||
_proposal_with_sent_draft(sess)
|
_proposal_with_sent_draft(sess)
|
||||||
raw = _eml("Re: hi", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
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
|
first, second = maild._poll(cfg(), sess, fetcher=_fetcher([raw])), maild._poll(
|
||||||
assert maild._poll(cfg(), sess, fetcher=fetcher)["skipped"] == 1
|
cfg(), sess, fetcher=_fetcher([raw])
|
||||||
|
)
|
||||||
|
assert first[0]["processed"] == 1
|
||||||
|
assert second[0]["skipped"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_unthreaded_approval_needs_review(cfg, sess):
|
def test_unthreaded_approval_needs_review(cfg, sess):
|
||||||
raw = _eml("Great news", "Approved! Welcome aboard.") # no In-Reply-To
|
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()
|
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||||
assert inbound.classification is MailClassification.NEEDS_REVIEW
|
assert inbound.classification is MailClassification.NEEDS_REVIEW
|
||||||
assert stats["needs_review"] == 1
|
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):
|
def test_question_gets_clarification_draft(cfg, sess):
|
||||||
_proposal_with_sent_draft(sess)
|
_proposal_with_sent_draft(sess)
|
||||||
raw = _eml("Re: inquiry", "Could you provide your monthly traffic?", in_reply_to=OUTBOUND_MSGID)
|
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()
|
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||||
assert inbound.classification is MailClassification.QUESTION
|
assert inbound.classification is MailClassification.QUESTION
|
||||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||||
@@ -117,42 +215,318 @@ def test_question_gets_clarification_draft(cfg, sess):
|
|||||||
# --- human gate -------------------------------------------------------------------
|
# --- human gate -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_send_blocked_without_approval(cfg, sess, monkeypatch):
|
def test_send_blocked_without_approval(cfg, sess):
|
||||||
_proposal_with_sent_draft(sess)
|
_proposal_with_sent_draft(sess)
|
||||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
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 = []
|
sent = []
|
||||||
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||||
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
|
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
|
||||||
assert stats["sent"] == 0 and sent == []
|
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):
|
def test_send_requires_explicit_human_approval(cfg, sess):
|
||||||
_proposal_with_sent_draft(sess)
|
_proposal_with_sent_draft(sess)
|
||||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
raw = _eml(
|
||||||
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
"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()
|
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, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||||
|
)
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
sent = []
|
sent = []
|
||||||
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
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 stats["sent"] == 1
|
||||||
assert inbound.draft_status is DraftStatus.SENT
|
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()
|
# a real OUTBOUND row now exists (audit fix: they were never created)
|
||||||
assert "gustavo" in str(row.detail)
|
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 "operator" in str(row.detail)
|
||||||
|
|
||||||
|
|
||||||
def test_double_approve_is_rejected(cfg, sess):
|
def test_double_approve_is_rejected(cfg, sess):
|
||||||
_proposal_with_sent_draft(sess)
|
_proposal_with_sent_draft(sess)
|
||||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
raw = _eml("Re: inquiry", "Approved, welcome.", 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()
|
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||||
|
|
||||||
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0
|
assert (
|
||||||
assert maild._approve_draft(inbound.id, "gustavo", sess) == 1
|
maild._approve_draft(
|
||||||
|
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||||
|
)
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
assert maild._approve_draft(inbound.id, "operator", 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, "operator", 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, "operator", 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, "operator", 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, "operator", 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, "operator", 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, "operator", 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, "operator", 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, "operator", 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"
|
||||||
|
|||||||
+56
-4
@@ -37,7 +37,7 @@ def test_proposal_status_transitions(sess):
|
|||||||
sess.flush()
|
sess.flush()
|
||||||
|
|
||||||
p.status = ProposalStatus.APPROVED
|
p.status = ProposalStatus.APPROVED
|
||||||
p.approved_by = "gustavo"
|
p.approved_by = "operator"
|
||||||
p.approved_at = datetime.now(UTC)
|
p.approved_at = datetime.now(UTC)
|
||||||
p.status = ProposalStatus.RUNNING
|
p.status = ProposalStatus.RUNNING
|
||||||
p.status = ProposalStatus.DONE
|
p.status = ProposalStatus.DONE
|
||||||
@@ -45,7 +45,7 @@ def test_proposal_status_transitions(sess):
|
|||||||
|
|
||||||
loaded = sess.scalar(select(Proposal).where(Proposal.id == p.id))
|
loaded = sess.scalar(select(Proposal).where(Proposal.id == p.id))
|
||||||
assert loaded.status == ProposalStatus.DONE
|
assert loaded.status == ProposalStatus.DONE
|
||||||
assert loaded.approved_by == "gustavo"
|
assert loaded.approved_by == "operator"
|
||||||
|
|
||||||
|
|
||||||
def test_dedupe_key_unique(sess):
|
def test_dedupe_key_unique(sess):
|
||||||
@@ -65,12 +65,12 @@ def test_audit_helper_writes(sess):
|
|||||||
p = Proposal(kind="other", title="x")
|
p = Proposal(kind="other", title="x")
|
||||||
sess.add(p)
|
sess.add(p)
|
||||||
sess.flush()
|
sess.flush()
|
||||||
audit(sess, actor="gustavo", action="approve", proposal_id=p.id, detail={"note": "ok"})
|
audit(sess, actor="operator", action="approve", proposal_id=p.id, detail={"note": "ok"})
|
||||||
sess.flush()
|
sess.flush()
|
||||||
|
|
||||||
rows = list(sess.scalars(select(AuditEntry)).all())
|
rows = list(sess.scalars(select(AuditEntry)).all())
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
assert rows[0].actor == "gustavo"
|
assert rows[0].actor == "operator"
|
||||||
assert rows[0].proposal_id == p.id
|
assert rows[0].proposal_id == p.id
|
||||||
|
|
||||||
|
|
||||||
@@ -90,3 +90,55 @@ def test_mail_and_content_and_runs(sess):
|
|||||||
assert m.id and c.id and r.id
|
assert m.id and c.id and r.id
|
||||||
assert m.draft_status == "drafted"
|
assert m.draft_status == "drafted"
|
||||||
assert c.status == "draft"
|
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()
|
page = out.read_text()
|
||||||
assert "<script>x</script>" not in page
|
assert "<script>x</script>" not in page
|
||||||
assert "<script>x</script>" in page
|
assert "<script>x</script>" in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_signs_and_logs_once_per_day(cfg, sess, monkeypatch):
|
||||||
|
from cayceops.components.monitor import monitor
|
||||||
|
from cayceops.models import AuditEntry
|
||||||
|
|
||||||
|
# seed the chain
|
||||||
|
from cayceops.models import audit as audit_fn
|
||||||
|
|
||||||
|
audit_fn(sess, actor="daemon:executor", action="execute_done", detail={})
|
||||||
|
sess.flush()
|
||||||
|
|
||||||
|
published = []
|
||||||
|
|
||||||
|
def fake_publish(relays, event, timeout=10.0):
|
||||||
|
published.append(event)
|
||||||
|
return [{"relay": r, "ok": "true"} for r in relays]
|
||||||
|
|
||||||
|
import cayceops.components.publisher.nostr as nostr_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(nostr_mod, "publish", fake_publish)
|
||||||
|
|
||||||
|
config = cfg(
|
||||||
|
CAYCE_OPS_NOSTR_PRIVATE_KEY="11" * 32,
|
||||||
|
CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org",
|
||||||
|
CAYCE_OPS_DEPLOYMENT_ID="itest",
|
||||||
|
)
|
||||||
|
code = monitor._anchor(config, sess)
|
||||||
|
assert code == 0
|
||||||
|
assert len(published) == 1
|
||||||
|
content = published[0]["content"]
|
||||||
|
assert "audit anchor" in content and "itest" in content
|
||||||
|
entry = sess.query(AuditEntry).filter_by(action="anchor").one()
|
||||||
|
assert entry.detail["last_hash"]
|
||||||
|
|
||||||
|
# second call same day -> skipped, no new nostr event
|
||||||
|
code2 = monitor._anchor(config, sess)
|
||||||
|
assert code2 == 0
|
||||||
|
assert len(published) == 1
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Tests for D16: the operator key may never be an agent identity.
|
||||||
|
|
||||||
|
Background: the operator key was, at some point, Cayce's nostr key. The
|
||||||
|
daemon must refuse to operate in that configuration, because "human
|
||||||
|
approval" signed by the agent is not human approval.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cayceops.signing import (
|
||||||
|
SelfApprovalError,
|
||||||
|
pubkey_from_private_hex,
|
||||||
|
validate_no_self_approval,
|
||||||
|
)
|
||||||
|
|
||||||
|
# keys invented for tests only (allowlisted in .gitleaks.toml)
|
||||||
|
CAYCE_SECRET = "11" * 32 # simulates Cayce nostr identity
|
||||||
|
CAYCE_PUBKEY = pubkey_from_private_hex(CAYCE_SECRET)
|
||||||
|
HUMAN_SECRET = "33" * 32 # a proper human operator key
|
||||||
|
HUMAN_PUBKEY = pubkey_from_private_hex(HUMAN_SECRET)
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(monkeypatch, operator_pubkey: str, nostr_private_key: str = CAYCE_SECRET):
|
||||||
|
from cayceops.config import Config
|
||||||
|
|
||||||
|
monkeypatch.setenv("CAYCE_OPS_DEPLOYMENT_ID", "itest")
|
||||||
|
monkeypatch.setenv("CAYCE_OPS_OPERATOR_PUBKEY", operator_pubkey)
|
||||||
|
monkeypatch.setenv("CAYCE_OPS_NOSTR_PRIVATE_KEY", nostr_private_key)
|
||||||
|
return Config()
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_pubkey_as_operator_is_refused(monkeypatch):
|
||||||
|
"""The exact bug: operator pubkey = Cayce pubkey must fail."""
|
||||||
|
cfg = _cfg(monkeypatch, operator_pubkey=CAYCE_PUBKEY)
|
||||||
|
with pytest.raises(SelfApprovalError):
|
||||||
|
validate_no_self_approval(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_pubkey_passes(monkeypatch):
|
||||||
|
cfg = _cfg(monkeypatch, operator_pubkey=HUMAN_PUBKEY)
|
||||||
|
validate_no_self_approval(cfg) # does not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_operator_pubkey_passes_validation(monkeypatch):
|
||||||
|
"""Empty is the 'not configured' state — rejected elsewhere, not here."""
|
||||||
|
cfg = _cfg(monkeypatch, operator_pubkey="")
|
||||||
|
validate_no_self_approval(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_denylist_is_honoured(monkeypatch):
|
||||||
|
"""A key held outside this process can be listed explicitly."""
|
||||||
|
outside_pubkey = pubkey_from_private_hex("44" * 32)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS", f"{outside_pubkey}, {HUMAN_PUBKEY}"
|
||||||
|
)
|
||||||
|
cfg = _cfg(monkeypatch, operator_pubkey=HUMAN_PUBKEY, nostr_private_key="")
|
||||||
|
with pytest.raises(SelfApprovalError):
|
||||||
|
validate_no_self_approval(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_daemon_refuses_to_start_with_agent_key(monkeypatch, sess, db_engine):
|
||||||
|
"""End-to-end: executor.run must refuse (exit 2) when the operator
|
||||||
|
pubkey is Cayce's identity."""
|
||||||
|
from cayceops.components.executor import executor
|
||||||
|
from cayceops.models import DaemonRun
|
||||||
|
|
||||||
|
cfg = _cfg(monkeypatch, operator_pubkey=CAYCE_PUBKEY)
|
||||||
|
code = executor.run(cfg, sess)
|
||||||
|
assert code == 2
|
||||||
|
run_row = sess.query(DaemonRun).filter_by(daemon="executor").one()
|
||||||
|
assert run_row.status == "error"
|
||||||
|
assert "agent identity" in run_row.detail["refused"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pubkey_derivation_roundtrip():
|
||||||
|
assert pubkey_from_private_hex(HUMAN_SECRET) == HUMAN_PUBKEY
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pubkey_from_private_hex("")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pubkey_from_private_hex("nothex")
|
||||||
+128
-27
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cayceops.components.publisher import adapters, nostr, publisher
|
from cayceops.components.publisher import adapters, nostr, publisher
|
||||||
from cayceops.models import AuditEntry, ContentItem, ContentStatus
|
from cayceops.models import AuditEntry, ContentItem, ContentStatus
|
||||||
|
|
||||||
# invented test key (never a real nostr key — allowlisted in .gitleaks.toml)
|
# invented test keys (never real — allowlisted in .gitleaks.toml)
|
||||||
TEST_KEY = "11" * 32
|
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):
|
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, "operator", sess, signature=_sign_content(item)) == 0
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
# --- nostr client -------------------------------------------------------------
|
# --- nostr client -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_event_build_verify_roundtrip():
|
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 nostr.verify_event(event)
|
||||||
assert len(event["id"]) == 64 and len(event["sig"]) == 128
|
assert len(event["id"]) == 64 and len(event["sig"]) == 128
|
||||||
|
|
||||||
@@ -63,22 +89,26 @@ def test_approve_gate(cfg, sess):
|
|||||||
item = _item(status=ContentStatus.DRAFT)
|
item = _item(status=ContentStatus.DRAFT)
|
||||||
sess.add(item)
|
sess.add(item)
|
||||||
sess.flush()
|
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, "operator", sess, signature=None) == 2
|
||||||
|
# signed approval accepted, FULL signature stored
|
||||||
|
sig = _sign_content(item)
|
||||||
|
assert publisher._approve(item.id, "operator", sess, signature=sig) == 0
|
||||||
assert item.status is ContentStatus.APPROVED
|
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, "operator", sess, signature=sig) == 1 # not draft anymore
|
||||||
|
|
||||||
actor = sess.query(AuditEntry).filter_by(action="approve_content").first().actor
|
actor = sess.query(AuditEntry).filter_by(action="approve_content").first().actor
|
||||||
assert actor == "gustavo"
|
assert actor == "operator"
|
||||||
|
|
||||||
|
|
||||||
# --- publishing ------------------------------------------------------------------
|
# --- publishing ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_publish_due_nostr_happy_path(cfg, sess):
|
def test_publish_due_nostr_happy_path(cfg, sess):
|
||||||
item = _item() # approved, scheduled None -> due now
|
item = _approved_item(cfg, sess)
|
||||||
sess.add(item)
|
|
||||||
sess.flush()
|
|
||||||
|
|
||||||
relays_seen = []
|
relays_seen = []
|
||||||
|
|
||||||
@@ -101,21 +131,65 @@ def test_publish_due_nostr_happy_path(cfg, sess):
|
|||||||
|
|
||||||
|
|
||||||
def test_future_scheduled_item_not_published(cfg, sess):
|
def test_future_scheduled_item_not_published(cfg, sess):
|
||||||
from datetime import UTC, datetime, timedelta
|
item = _approved_item(
|
||||||
|
cfg, sess, scheduled=datetime.now(UTC) + timedelta(hours=2)
|
||||||
item = _item(scheduled=datetime.now(UTC) + timedelta(hours=2))
|
)
|
||||||
sess.add(item)
|
config = cfg(
|
||||||
sess.flush()
|
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
|
||||||
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: [])
|
stats = publisher._publish_due(
|
||||||
|
config, sess, nostr_sender=lambda r, e, timeout=10: []
|
||||||
|
)
|
||||||
assert stats["published"] == 0
|
assert stats["published"] == 0
|
||||||
assert item.status is ContentStatus.APPROVED
|
assert item.status is ContentStatus.APPROVED
|
||||||
|
|
||||||
|
|
||||||
def test_nostr_without_config_fails_clean(cfg, sess):
|
def test_unsigned_sql_approved_item_not_published(cfg, sess):
|
||||||
item = _item()
|
"""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.add(item)
|
||||||
sess.flush()
|
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, "operator", 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
|
config = cfg() # no key, no relays
|
||||||
stats = publisher._publish_due(config, sess)
|
stats = publisher._publish_due(config, sess)
|
||||||
assert stats["failed"] == 1
|
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
|
assert item.error and "nostr" in item.error
|
||||||
|
|
||||||
|
|
||||||
def test_non_nostr_channels_skipped_by_default(cfg, sess):
|
def test_non_nostr_channels_skipped_terminal(cfg, sess):
|
||||||
item = _item(channel="facebook", theme="lightning-abroad")
|
item = _approved_item(cfg, sess, channel="facebook", theme="lightning-abroad")
|
||||||
sess.add(item)
|
|
||||||
sess.flush()
|
|
||||||
stats = publisher._publish_due(cfg(), sess)
|
stats = publisher._publish_due(cfg(), sess)
|
||||||
assert stats["skipped"] == 1
|
assert stats["skipped"] == 1
|
||||||
assert item.status is ContentStatus.APPROVED
|
assert item.status is ContentStatus.SKIPPED # terminal (audit fix)
|
||||||
skip = sess.query(AuditEntry).filter_by(action="publish_skipped").one()
|
|
||||||
assert skip.detail["channel"] == "facebook"
|
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):
|
def test_meta_adapters_disabled_by_default(cfg):
|
||||||
@@ -160,3 +235,29 @@ def test_unknown_brand_rejected():
|
|||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
images.render_brand_image(brand="xx", kicker=None, lines=["A"], out_path="/tmp/x.jpg")
|
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"]
|
affiliate = by_title["Acme launches affiliate program for hotels"]
|
||||||
assert affiliate.kind == "affiliate"
|
assert affiliate.kind == "affiliate"
|
||||||
assert affiliate.status == ProposalStatus.PROPOSED
|
assert affiliate.status == ProposalStatus.PROPOSED
|
||||||
assert affiliate.risk_score > 0
|
assert affiliate.match_score > 0
|
||||||
assert affiliate.dedupe_key
|
assert affiliate.dedupe_key
|
||||||
|
|
||||||
partner = by_title["Become a payment partner"]
|
partner = by_title["Become a payment partner"]
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Spanish negation tests (round-3 point 4): merchants write in Spanish;
|
||||||
|
negations like 'nunca', 'lamentablemente' or 'no podemos' must veto
|
||||||
|
`approval` exactly like their English counterparts. Veto rule unchanged:
|
||||||
|
when in doubt -> needs_review."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cayceops.components.maild import classify as clf
|
||||||
|
from cayceops.models import MailClassification
|
||||||
|
|
||||||
|
APPROVAL_ONLY = MailClassification.APPROVAL
|
||||||
|
|
||||||
|
|
||||||
|
def _never_approval(cfg_factory, text: str) -> None:
|
||||||
|
kind, _ = clf.classify(text, cfg_factory())
|
||||||
|
assert kind is not APPROVAL_ONLY, f"{text!r} classified as APPROVAL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_english_baseline_still_works(cfg):
|
||||||
|
config = cfg()
|
||||||
|
kind, _ = clf.classify("Your application was approved, welcome to the program", config)
|
||||||
|
assert kind is APPROVAL_ONLY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text",
|
||||||
|
[
|
||||||
|
# 1. rejection stem
|
||||||
|
"Lamentamos informarle que su solicitud fue rechazada",
|
||||||
|
# 2. nunca
|
||||||
|
"Nunca aceptamos negocios de criptomonedas",
|
||||||
|
# 3. jamás
|
||||||
|
"Jamás hemos trabajado con affiliates externos",
|
||||||
|
# 4. tampoco
|
||||||
|
"No podemos aceptar su sitio, tampoco podemos ofrecerle otro acuerdo",
|
||||||
|
# 5. ningún/ninguna
|
||||||
|
"En este momento no tenemos ningún hueco para nuevos partners",
|
||||||
|
# 6. no podemos (multi-word)
|
||||||
|
"Su propuesta es interesante pero no podemos proceder",
|
||||||
|
# 7. no es posible (multi-word)
|
||||||
|
"Gracias por escribir, pero no es posible colaborar este trimestre",
|
||||||
|
# 8. lamentablemente
|
||||||
|
"Lamentablemente su aplicación no ha sido aprobada",
|
||||||
|
# 9. no aceptamos
|
||||||
|
"Por política interna no aceptamos sitios de viajes",
|
||||||
|
# 10. mixed spanish/english: negation in spanish, approval word in english
|
||||||
|
"Thanks for reaching out! Lamentablemente we cannot approve your site",
|
||||||
|
# 11. mixed: approval in spanish, negation in english
|
||||||
|
"Apreciamos su interés, but we are not accepting new partners",
|
||||||
|
# 12. nadie / nada
|
||||||
|
"Aquí nadie decidiría eso, así que no hay nada que aprobar",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_spanish_and_mixed_negations_veto_approval(cfg, text):
|
||||||
|
_never_approval(cfg, text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spanish_pure_approval_still_approves(cfg):
|
||||||
|
"""A clean Spanish approval WITHOUT any negation must still classify
|
||||||
|
approval — the veto must not over-fire."""
|
||||||
|
config = cfg()
|
||||||
|
kind, confidence = clf.classify(
|
||||||
|
"Su solicitud fue aprobada. Bienvenido al programa, estamos encantados "
|
||||||
|
"de trabajar con ustedes.",
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
assert kind is APPROVAL_ONLY
|
||||||
|
assert confidence >= 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_spanish_rejection_classifies_rejection(cfg):
|
||||||
|
config = cfg()
|
||||||
|
kind, conf = clf.classify(
|
||||||
|
"Lamentamos informarle que su solicitud fue rechazada por el comité",
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
assert kind is MailClassification.REJECTION
|
||||||
|
assert conf >= 0.9
|
||||||
Reference in New Issue
Block a user