Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2dcd4fca | ||
|
|
121183ac83 | ||
|
|
b87a51afc0 | ||
|
|
d527164434 | ||
|
|
1c4ba234d0 | ||
|
|
64f89df289 | ||
|
|
f5b026070b | ||
|
|
60300a2f1a | ||
|
|
09fe9199b3 | ||
|
|
029e4f77d7 | ||
|
|
0d24309433 | ||
|
|
53ea15ee51 | ||
|
|
a2de419467 | ||
|
|
13db3996d9 | ||
|
|
aba42a4210 |
@@ -175,3 +175,31 @@ issues. All criticals and all mediums are fixed in **c9bd441** (PR #9):
|
||||
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,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
|
||||
[bitcoinhotels.org](https://bitcoinhotels.org) (Bitcoin-native travel guides)
|
||||
and [hardcap.news](https://hardcap.news) (Bitcoin security intelligence).
|
||||
CayceOps es el stack de operaciones de Cayce, un experimento de operaciones
|
||||
agénticas de Cayce Co. Toda acción con efectos externos requiere aprobación
|
||||
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,
|
||||
driven by cron/systemd timers. Nothing runs without an audit trail.
|
||||
|
||||
@@ -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;"))
|
||||
@@ -41,14 +41,20 @@ GATE_ERROR = 2 # missing/invalid configuration or signature
|
||||
|
||||
|
||||
def _signing(cfg: Config) -> tuple[str, str]:
|
||||
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode."""
|
||||
"""(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
|
||||
|
||||
|
||||
|
||||
@@ -19,10 +19,17 @@ 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
|
||||
# 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)\b",
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -34,11 +41,24 @@ _RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
|
||||
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
|
||||
|
||||
@@ -43,14 +43,20 @@ GATE_ERROR = 2 # missing/invalid configuration or signature
|
||||
|
||||
|
||||
def _signing(cfg: Config) -> tuple[str, str]:
|
||||
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode."""
|
||||
"""(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
|
||||
|
||||
|
||||
|
||||
@@ -251,3 +251,120 @@ def build(cfg: Config | None = None) -> int:
|
||||
return 0
|
||||
finally:
|
||||
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
|
||||
|
||||
@@ -118,13 +118,20 @@ 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
|
||||
|
||||
|
||||
|
||||
+11
-4
@@ -209,14 +209,21 @@ def _build_entry(
|
||||
def verify_audit_chain(sess) -> tuple[bool, int | None]:
|
||||
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None).
|
||||
|
||||
Legacy rows (entry_hash IS NULL, written before migration 0002) are
|
||||
skipped and documented: the chain begins GENESIS at the first hashed row,
|
||||
so a migrated database verifies clean without any tampering.
|
||||
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:
|
||||
continue # legacy row: outside the chain by documented genesis
|
||||
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:
|
||||
|
||||
@@ -101,3 +101,72 @@ def verify_signature(
|
||||
)
|
||||
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,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,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
|
||||
@@ -117,3 +117,30 @@ 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.
|
||||
|
||||
@@ -197,7 +197,7 @@ def test_roles_full_daemon_cycle(roles_applied):
|
||||
sig = "c9" * 64 # invented 128-hex signature for the permission test
|
||||
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE proposals SET status='APPROVED', approved_by='gustavo', "
|
||||
f"UPDATE proposals SET status='APPROVED', approved_by='operator', "
|
||||
f"approved_at=now(), operator_signature='{sig}' WHERE id={proposal_id}"
|
||||
)
|
||||
|
||||
@@ -225,7 +225,7 @@ def test_roles_full_daemon_cycle(roles_applied):
|
||||
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE mail_messages SET draft_status='APPROVED', "
|
||||
"approved_by='gustavo', approved_hash='"
|
||||
"approved_by='operator', approved_hash='"
|
||||
+ "a" * 64
|
||||
+ f"', operator_signature='{sig}' WHERE id={mail_id}"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
+13
-13
@@ -23,13 +23,13 @@ def test_approve_requires_proposed_state(cfg, sess, signer):
|
||||
sess.flush()
|
||||
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
# second approve must fail (state is now approved)
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 1
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 1
|
||||
)
|
||||
assert p.approved_by == "gustavo"
|
||||
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
|
||||
@@ -39,7 +39,7 @@ def test_reject_records_actor(cfg, sess):
|
||||
p = _proposal()
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
assert executor.reject(p.id, "gustavo", cfg(), sess) == 0
|
||||
assert executor.reject(p.id, "operator", cfg(), sess) == 0
|
||||
assert p.status is ProposalStatus.REJECTED
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ def test_run_executes_only_approved(cfg, sess, signer):
|
||||
sess.flush()
|
||||
assert (
|
||||
executor.approve(
|
||||
approved.id, "gustavo", cfg(), sess, signature=signer.proposal(approved)
|
||||
approved.id, "operator", cfg(), sess, signature=signer.proposal(approved)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
@@ -110,18 +110,18 @@ def test_signed_approval_mandatory_and_content_bound(cfg, sess, signer):
|
||||
chash = proposal_content_hash(p.kind, p.title, p.url, p.detail)
|
||||
|
||||
# no signature -> refused
|
||||
assert executor.approve(p.id, "gustavo", cfg(), sess, signature=None) == 2
|
||||
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, "gustavo", cfg(), sess, signature=other_dep) == 2
|
||||
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, "gustavo", cfg(), sess, signature=other_content) == 2
|
||||
assert executor.approve(p.id, "operator", cfg(), sess, signature=other_content) == 2
|
||||
# valid signature -> approved
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
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()
|
||||
@@ -135,7 +135,7 @@ def test_approve_fails_audited_from_cli_wrapper(cfg, sess):
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
code = executor.approve(p.id, "gustavo", cfg(), sess, signature=None)
|
||||
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"]
|
||||
@@ -170,7 +170,7 @@ def test_execution_verifies_signature_against_current_content(cfg, sess, signer,
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
p.title = "EDITED after approval" # content changed under the signature
|
||||
@@ -191,7 +191,7 @@ def test_failing_plugin_retries_then_fails(cfg, sess, signer, monkeypatch):
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
calls = {"n": 0}
|
||||
@@ -232,7 +232,7 @@ def test_unknown_kind_falls_back_to_log_action(cfg, sess, signer):
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
assert (
|
||||
executor.approve(p.id, "gustavo", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
assert executor.run(cfg(), sess) == 0
|
||||
|
||||
+15
-13
@@ -77,7 +77,7 @@ def _proposal_with_sent_draft(sess) -> Proposal:
|
||||
p.id, "partners@acme.example.org", "[cayce] partnership inquiry", "Hello!", sess
|
||||
)
|
||||
assert (
|
||||
maild._approve_draft(mail_id, "gustavo", sess, signature=_approve_signed(sess, mail_id))
|
||||
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"
|
||||
@@ -243,7 +243,7 @@ def test_send_requires_explicit_human_approval(cfg, sess):
|
||||
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
@@ -277,7 +277,7 @@ def test_send_requires_explicit_human_approval(cfg, sess):
|
||||
.order_by(AuditEntry.id.desc())
|
||||
.first()
|
||||
)
|
||||
assert row is not None and "gustavo" in str(row.detail)
|
||||
assert row is not None and "operator" in str(row.detail)
|
||||
|
||||
|
||||
def test_double_approve_is_rejected(cfg, sess):
|
||||
@@ -288,11 +288,11 @@ def test_double_approve_is_rejected(cfg, sess):
|
||||
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert maild._approve_draft(inbound.id, "gustavo", sess) == 1
|
||||
assert maild._approve_draft(inbound.id, "operator", sess) == 1
|
||||
|
||||
|
||||
# --- audit corrections ----------------------------------------------------------
|
||||
@@ -311,7 +311,9 @@ def test_send_failure_does_not_resurrect_sent_mails(cfg, sess):
|
||||
p.id, f"desk{i}@acme.example.org", f"[cayce] inquiry {i}", "Hello!", sess
|
||||
)
|
||||
assert (
|
||||
maild._approve_draft(mail_id, "gustavo", sess, signature=_approve_signed(sess, mail_id))
|
||||
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"
|
||||
@@ -331,7 +333,7 @@ def test_send_failure_does_not_resurrect_sent_mails(cfg, sess):
|
||||
)
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
@@ -368,7 +370,7 @@ def test_edited_draft_refuses_to_send(cfg, sess):
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
@@ -402,7 +404,7 @@ def test_compose_creates_first_contact_draft(cfg, sess):
|
||||
|
||||
# approve + send -> an inbound reply can now thread to this proposal
|
||||
assert (
|
||||
maild._approve_draft(mail_id, "gustavo", sess, signature=_approve_signed(sess, mail_id))
|
||||
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)
|
||||
@@ -462,7 +464,7 @@ def test_changed_to_addr_after_approval_blocks_send(cfg, sess):
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
@@ -491,14 +493,14 @@ def test_foreign_deployment_signature_refused(cfg, sess):
|
||||
other_dep = _sign(
|
||||
OPERATOR_SECRET, approval_message("mail", "other-deploy", inbound.id, chash)
|
||||
)
|
||||
assert maild._approve_draft(inbound.id, "gustavo", sess, signature=other_dep) == 2
|
||||
assert 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, "gustavo", sess, signature=other_content) == 2
|
||||
assert maild._approve_draft(inbound.id, "operator", sess, signature=other_content) == 2
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||
|
||||
|
||||
@@ -512,7 +514,7 @@ def test_send_failures_audited_and_exit_code(cfg, sess, monkeypatch):
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "gustavo", sess, signature=_approve_signed(sess, inbound.id)
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_proposal_status_transitions(sess):
|
||||
sess.flush()
|
||||
|
||||
p.status = ProposalStatus.APPROVED
|
||||
p.approved_by = "gustavo"
|
||||
p.approved_by = "operator"
|
||||
p.approved_at = datetime.now(UTC)
|
||||
p.status = ProposalStatus.RUNNING
|
||||
p.status = ProposalStatus.DONE
|
||||
@@ -45,7 +45,7 @@ def test_proposal_status_transitions(sess):
|
||||
|
||||
loaded = sess.scalar(select(Proposal).where(Proposal.id == p.id))
|
||||
assert loaded.status == ProposalStatus.DONE
|
||||
assert loaded.approved_by == "gustavo"
|
||||
assert loaded.approved_by == "operator"
|
||||
|
||||
|
||||
def test_dedupe_key_unique(sess):
|
||||
@@ -65,12 +65,12 @@ def test_audit_helper_writes(sess):
|
||||
p = Proposal(kind="other", title="x")
|
||||
sess.add(p)
|
||||
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()
|
||||
|
||||
rows = list(sess.scalars(select(AuditEntry)).all())
|
||||
assert len(rows) == 1
|
||||
assert rows[0].actor == "gustavo"
|
||||
assert rows[0].actor == "operator"
|
||||
assert rows[0].proposal_id == p.id
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -44,7 +44,7 @@ def _approved_item(cfg, sess, **kw) -> ContentItem:
|
||||
item = _item(status=ContentStatus.DRAFT, **kw)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
assert publisher._approve(item.id, "gustavo", sess, signature=_sign_content(item)) == 0
|
||||
assert publisher._approve(item.id, "operator", sess, signature=_sign_content(item)) == 0
|
||||
return item
|
||||
|
||||
|
||||
@@ -92,16 +92,16 @@ def test_approve_gate(cfg, sess):
|
||||
cfg() # signing env first
|
||||
|
||||
# unsigned approval refused (mandatory signature, no degraded mode)
|
||||
assert publisher._approve(item.id, "gustavo", sess, signature=None) == 2
|
||||
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, "gustavo", sess, signature=sig) == 0
|
||||
assert publisher._approve(item.id, "operator", sess, signature=sig) == 0
|
||||
assert item.status is ContentStatus.APPROVED
|
||||
assert item.operator_signature == sig and len(sig) == 128
|
||||
assert publisher._approve(item.id, "gustavo", sess, signature=sig) == 1 # not draft anymore
|
||||
assert publisher._approve(item.id, "operator", sess, signature=sig) == 1 # not draft anymore
|
||||
|
||||
actor = sess.query(AuditEntry).filter_by(action="approve_content").first().actor
|
||||
assert actor == "gustavo"
|
||||
assert actor == "operator"
|
||||
|
||||
|
||||
# --- publishing ------------------------------------------------------------------
|
||||
@@ -183,7 +183,7 @@ def test_foreign_deployment_signature_refused(cfg, sess, signer):
|
||||
other_dep = signer.raw(
|
||||
signer.msg("content", "other-deploy", item.id, signer.content_hash(item))
|
||||
)
|
||||
assert publisher._approve(item.id, "gustavo", sess, signature=other_dep) == 2
|
||||
assert publisher._approve(item.id, "operator", sess, signature=other_dep) == 2
|
||||
assert item.status is ContentStatus.DRAFT
|
||||
|
||||
|
||||
|
||||
@@ -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