Compare commits

...
Author SHA1 Message Date
cayce f5b026070b audit-4: classifier understands Spanish negations (veto unchanged)
ci / lint-test-leaks (pull_request) Canceled after 0s
Merchants write in Spanish; negations like nunca, lamentablemente or
no podemos must veto approval exactly like their English counterparts.
Previously an all-Spanish rejection mail could fall through to OTHER,
and a mixed mail with the approval word in English approved despite a
Spanish negation.

- negation list adds: nunca, jamas, tampoco, ningun/ninguna, nadie,
  nada, no podemos, no es posible, no aceptamos, rechaz*, descartad*,
  lamentablemente, no ha sido aprobad*, no procede
- new Spanish REJECTION rule (rechaz*, solicitud denegada, no cumple
  los requisitos) and Spanish APPROVAL rule (aprob*, aceptad*,
  bienvenid*, encantados de trabajar)
- veto rule unchanged: any negation anywhere -> needs_review
- tests: 15 new (12 veto cases incl. 2 mixed-language, pure-Spanish
  approval still approves, Spanish rejection classifies rejection)

Gate: ruff 0 · 96 unit + 1 PG integration · gitleaks clean.
2026-10-28 19:59:44 +00:00
cayce 60300a2f1a Merge PR #10: cadena de auditoría a prueba de dueño
ci / lint-test-leaks (push) Canceled after 0s
2026-10-28 19:56:57 +00:00
cayce 09fe9199b3 audit-3: append-only audit log + strict legacy-prefix rule + anchor cross-check
ci / lint-test-leaks (pull_request) Canceled after 0s
- verify_audit_chain(): rows with entry_hash IS NULL are only tolerated
  BEFORE the first hashed row (documented legacy prefix). A NULL hash
  AFTER the chain started is tampering and fails verification at that
  row id — previously any NULL row anywhere was silently skipped, so an
  attacker with UPDATE could erase intermediate evidence undetected.
- migration 0004: audit_log becomes append-only. SQLite: BEFORE UPDATE/
  DELETE triggers RAISE(ABORT). Postgres: BEFORE triggers RAISE EXCEPTION.
  Closes the hole for the table owner and direct SQL connections.
- monitor.verify_chain_against_anchors(): compares the chain tail against
  anchors published to Nostr. Explicit statuses: no_anchors (never
  anchored / notes missing on relays), local_only (no fetcher), ok
  (relay note attests the tail or the last local anchor), mismatch.
- tests: NULL-before-genesis ok, NULL-in-middle fails at that id, NULL
  late fails, UPDATE rejected, DELETE rejected, INSERT still allowed,
  no-anchors explicit, local-only explicit, mismatch detected, match ok.

Gate: ruff 0 · 81 unit + 1 PG integration · alembic 0001→0004 on PG ·
gitleaks clean · make check exit 0.
2026-10-28 19:56:50 +00:00
6 changed files with 508 additions and 6 deletions
@@ -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;"))
+22 -2
View File
@@ -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
+117
View File
@@ -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
+11 -4
View File
@@ -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:
+189
View File
@@ -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"
+80
View File
@@ -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