trading-system-docs/tolaria/c5-sync-service/test_c5_delete_fresh_checker.py
Red Queen 3db1c71b68 C5: Human-Gated DELETE Execution Contract (Todo 7-13)
- DeleteExecutor (rq_c5_delete.py): Pre-Gates, Read-Back, idempotenter replay
- TolariaClient.delete() (rq_c5c.py): kontrollierter DELETE, keine Probes
- Approval-Store + Reason Codes RC_DELETE_APPROVAL_MISSING/MISMATCH (rq_c5a.py)
- CLI: c5-delete-approve/execute/replay/status (rq_c5_cli.py)
- C5E: recover()/replay() DELETE-Integration
- C5D: verify_integrity prueft secret_blocked_objects (FAIL CLOSED)
- Security: Path-Traversal-Block in _normalize_vault_path
- Drift nach DELETE -> FAIL CLOSED zurueck zu HUMAN_REVIEW_REQUIRED
- Tests: test_c5_delete (19), test_c5_delete_integration (22),
  test_c5_delete_fresh_checker (17) — alle gruen
- ADR: C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md (ACCEPTED)
2026-08-26 19:22:44 +00:00

420 lines
17 KiB
Python

#!/usr/bin/env python3
"""
Red Queen — C5 DELETE-Execution FRESH CHECKER (Todo 12).
Unabhaengiger, adversarialer Checker. Versucht AUSDRUECKLICH, den
Human-Gated-DELETE-Contract zu brechen. Nutzt NUR die Produktionsmodule
(rq_c5a/rq_c5c/rq_c5_delete/rq_c5d) + isolierte Fakes — KEINE Maker-Testdateien,
KEINE Maker-Argumentation. Jeder Check ist ein eigenstaendiger Angriffsversuch.
Angriffsvektoren (Todo 12):
wrong commit / wrong object / wrong path / wrong nonce
missing approval / used approval / stale approval
already absent / unexpected drift / crash after delete
restart / search mismatch
cross-object approval / cross-commit approval
path traversal / arbitrary path deletion
no auto-approval / no SQL bypass / no direct APPLIED bypass
DELETE bleibt HUMAN_GATED
Ergebnis: PASS (kein Defekt) oder FAIL (Defekt reproduziert).
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import unittest
from typing import Any, Dict, List, Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rq_c5a import (
C5AStore, ST_READY, ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED,
ST_DELETING, ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED,
RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH,
RC_UNEXPECTED_TOLARIA_DRIFT,
OP_DELETE_REQUEST, OP_CREATE,
)
from rq_c5c import TolariaClient, TolariaUnavailableError, RC_TOLARIA_UNAVAILABLE, VAULT_PREFIX
from rq_c5_delete import (
DeleteExecutor, DeleteApprovalError, DeleteExecutionError,
DELETE_ALREADY_AT_TARGET,
)
from rq_c5d import C5DEngine, SearchSourceBuilder, SearchClient
from rq_c5b import KnowledgeScope
UUID_A = "object/77b02661-d67b-4bae-b612-01d1287cea6b"
UUID_B = "object/48bd264f-607b-15f1-5f73-3e922af9b19d"
def _fm(id_: str, **extra) -> str:
lines = ["---", "knowledge_schema: 1", f"id: {id_}"]
for k, v in extra.items():
if isinstance(v, list):
lines.append(f"{k}: [{', '.join(v)}]")
else:
lines.append(f"{k}: {v}")
lines.append("---")
return "\n".join(lines) + "\n"
class FakeTolaria:
def __init__(self):
self.vault: Dict[str, str] = {}
self.delete_count = 0
self.unavailable = False
def read(self, p: str) -> Optional[str]:
if self.unavailable:
raise TolariaUnavailableError("down", RC_TOLARIA_UNAVAILABLE)
return self.vault.get(p)
def write(self, p: str, c: str) -> Dict[str, Any]:
self.vault[p] = c
return {"ok": True}
def delete(self, p: str) -> Dict[str, Any]:
if p in self.vault:
del self.vault[p]
self.delete_count += 1
return {"ok": True}
def list(self, p: str = "/app/vault") -> List[Dict[str, Any]]:
return [{"path": x} for x in self.vault]
class FakeTolariaClient(TolariaClient):
def __init__(self, fake: FakeTolaria):
super().__init__(base_url="http://fake")
self.fake = fake
def read(self, vault_path: str) -> Optional[str]:
return self.fake.read(vault_path)
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
return self.fake.write(vault_path, content)
def delete(self, vault_path: str) -> Dict[str, Any]:
return self.fake.delete(vault_path)
def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]:
return self.fake.list(vault_path)
class FakeSearch:
def __init__(self, source_path: str):
self.source_path = source_path
self.rebuild_count = 0
self.health_override: Optional[Dict[str, Any]] = None
self.indexed_ids: List[str] = []
self.indexed_paths: List[str] = []
def _load(self) -> Dict[str, Any]:
with open(self.source_path, "r", encoding="utf-8") as f:
return json.load(f)
def rebuild(self) -> Dict[str, Any]:
self.rebuild_count += 1
src = self._load()
objs = src.get("objects", [])
self.indexed_ids = [o["id"] for o in objs if o.get("id")]
self.indexed_paths = [o["path"] for o in objs]
return {"status": "ok", "indexed": len(objs)}
def health(self) -> Dict[str, Any]:
if self.health_override is not None:
return self.health_override
return {
"index_built": True, "object_count": len(self.indexed_ids),
"failed_objects": [], "integrity_ok": True,
"indexed_object_ids": self.indexed_ids,
"indexed_paths": self.indexed_paths,
"secret_blocked_objects": 0,
}
class FakeSearchClient(SearchClient):
def __init__(self, fake: FakeSearch):
super().__init__(base_url="http://fake-search")
self.fake = fake
def rebuild(self) -> Dict[str, Any]:
return self.fake.rebuild()
def health(self) -> Dict[str, Any]:
return self.fake.health()
def _make_store() -> C5AStore:
db = os.path.join(tempfile.mkdtemp(prefix="c5checker_db_"), "c5a.db")
return C5AStore(db)
def _seed_delete(store: C5AStore, sha: str, oid: str, path: str,
status: str = ST_HUMAN_REVIEW_REQUIRED) -> int:
store.upsert_commit({
"commit_sha": sha, "parent_sha": "p", "sequence": 1,
"status": status, "retry_count": 0,
})
oc = store.add_object_change({
"commit_sha": sha, "object_id": oid, "operation": OP_DELETE_REQUEST,
"path_before": path, "path_after": None,
})
if oc is None or oc.get("id") is None:
raise RuntimeError("keine id")
return oc["id"]
def _approve(store: C5AStore, sha: str, cid: int, oid: str, path: str) -> Dict[str, Any]:
return store.create_delete_approval(
workflow_commit=sha, object_change_id=cid, object_id=oid,
path=path, approved_by="human:christian")
class CheckerFixture:
def __init__(self):
self.store = _make_store()
self.fake = FakeTolaria()
self.client = FakeTolariaClient(self.fake)
self.tmpdir = tempfile.mkdtemp(prefix="c5checker_src_")
self.source_path = os.path.join(self.tmpdir, "index_source.json")
self.builder = SearchSourceBuilder(self.client, self.source_path,
scope=KnowledgeScope(VAULT_PREFIX))
self.search = FakeSearch(self.source_path)
self.search_client = FakeSearchClient(self.search)
self.engine = C5DEngine(self.store, search=self.search_client,
source_builder=self.builder)
def seed(self, objs: Dict[str, str]) -> None:
for p, c in objs.items():
self.fake.vault[p] = c
def cleanup(self) -> None:
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
class TestFreshChecker(unittest.TestCase):
"""Adversarialer Checker: versucht den Contract zu brechen."""
def setUp(self):
self.fx = CheckerFixture()
self.fx.seed({
"/app/vault/a.md": _fm(UUID_A, title="A"),
"/app/vault/b.md": _fm(UUID_B, title="B"),
})
def tearDown(self):
self.fx.cleanup()
def _exec(self) -> DeleteExecutor:
return DeleteExecutor(self.fx.store, self.fx.client)
# -- wrong commit / object / path / nonce --------------------------------
def test_wrong_commit_breaks(self):
"""Cross-commit: Approval fuer c2 darf c1 nicht freigeben."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c2", cid, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
with self.assertRaises(DeleteApprovalError):
self._exec().execute("c1")
self.assertEqual(self.fx.fake.delete_count, 0)
def test_wrong_object_breaks(self):
"""Cross-object: Approval fuer B darf A nicht freigeben."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", cid, UUID_B, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
with self.assertRaises(DeleteApprovalError):
self._exec().execute("c1")
self.assertEqual(self.fx.fake.delete_count, 0)
def test_wrong_path_breaks(self):
"""Cross-path: Approval fuer b.md darf a.md nicht freigeben."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", cid, UUID_A, "b.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
with self.assertRaises(DeleteApprovalError):
self._exec().execute("c1")
self.assertEqual(self.fx.fake.delete_count, 0)
def test_wrong_nonce_breaks(self):
"""Manipulierter nonce darf keinen zusaetzlichen Pfad oeffnen."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
# nonce wird nicht als Autorisierung genutzt; Approval bleibt korrekt
# gebunden. Der Checker verifiziert, dass nonce keinen Replay oeffnet.
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
r = self._exec().execute("c1")
self.assertEqual(r["status"], ST_UPDATING_SEARCH)
self.assertEqual(self.fx.fake.delete_count, 1)
# -- missing / used / stale approval ------------------------------------
def test_missing_approval_breaks(self):
"""Kein Delete ohne persistierte Approval."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
with self.assertRaises(DeleteApprovalError) as ctx:
self._exec().execute("c1")
self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISSING)
self.assertEqual(self.fx.fake.delete_count, 0)
def test_used_approval_breaks(self):
"""USED-Approval darf nicht erneut einen DELETE autorisieren."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
self._exec().execute("c1")
# Approval ist USED. Neuer Commit c2 mit gleicher Approval-id -> MISSING.
self.fx.store.upsert_commit({
"commit_sha": "c2", "parent_sha": "p", "sequence": 2,
"status": ST_DELETE_APPROVED, "retry_count": 0,
})
cid2 = self.fx.store.add_object_change({
"commit_sha": "c2", "object_id": UUID_A, "operation": OP_DELETE_REQUEST,
"path_before": "a.md", "path_after": None,
})
self.assertIsNotNone(cid2)
with self.assertRaises(DeleteApprovalError):
self._exec().execute("c2")
self.assertEqual(self.fx.fake.delete_count, 1)
def test_stale_approval_breaks(self):
"""Stale Approval (Commit nicht DELETE_APPROVED) -> kein DELETE."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
# Commit bleibt HUMAN_REVIEW_REQUIRED (nicht DELETE_APPROVED)
with self.assertRaises(DeleteExecutionError):
self._exec().execute("c1")
self.assertEqual(self.fx.fake.delete_count, 0)
# -- already absent / unexpected drift / crash --------------------------
def test_already_absent_no_second_delete(self):
"""Bereits absent -> kein zweiter destruktiver Write."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
del self.fx.fake.vault["/app/vault/a.md"]
r = self._exec().execute("c1")
self.assertEqual(r["idempotency"], DELETE_ALREADY_AT_TARGET)
self.assertEqual(self.fx.fake.delete_count, 0)
def test_unexpected_drift_breaks(self):
"""Unerwartete neue Objekte nach DELETE -> FAIL CLOSED."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
# Nach dem DELETE fuegt der Fake ein neues Objekt hinzu (Drift).
orig_delete = self.fx.fake.delete
def _delete_with_drift(p: str) -> Dict[str, Any]:
r = orig_delete(p)
self.fx.fake.vault["/app/vault/evil.md"] = _fm(UUID_B, title="EVIL")
return r
self.fx.fake.delete = _delete_with_drift
with self.assertRaises(DeleteExecutionError):
self._exec().execute("c1")
self.assertEqual(self.fx.store.commit_status("c1"), ST_HUMAN_REVIEW_REQUIRED)
def test_crash_after_delete_replay(self):
"""Crash nach DELETE: Replay -> DELETE_ALREADY_AT_TARGET, kein 2. Delete."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
self._exec().execute("c1")
# Commit ist ST_UPDATING_SEARCH. Replay -> nichts zu tun.
r = self._exec().replay("c1")
self.assertEqual(r["idempotency"], "ALREADY_AT_TARGET")
self.assertEqual(self.fx.fake.delete_count, 1)
def test_restart_after_approval(self):
"""Restart nach Approval: Approval persistent, DELETE ausfuehrbar."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
store2 = C5AStore(self.fx.store.db_path)
r = DeleteExecutor(store2, self.fx.client).execute("c1")
self.assertEqual(r["status"], ST_UPDATING_SEARCH)
self.assertEqual(self.fx.fake.delete_count, 1)
# -- search mismatch ----------------------------------------------------
def test_search_mismatch_no_applied(self):
"""Search-Mismatch -> KEIN APPLIED."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
self._exec().execute("c1")
# Stale Index mit geloeschtem Objekt
self.fx.search.health_override = {
"index_built": True, "object_count": 2,
"failed_objects": [], "integrity_ok": True,
"indexed_object_ids": [UUID_A, UUID_B],
"indexed_paths": ["a.md", "b.md"],
}
r = self.fx.engine.apply_commit("c1")
self.assertNotEqual(r["status"], ST_APPLIED)
# -- path traversal / arbitrary path ------------------------------------
def test_path_traversal_breaks(self):
"""Path-Traversal -> BLOCK, kein DELETE."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "../../etc/passwd")
_approve(self.fx.store, "c1", cid, UUID_A, "../../etc/passwd")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
with self.assertRaises(DeleteExecutionError):
self._exec().execute("c1")
self.assertEqual(self.fx.fake.delete_count, 0)
def test_arbitrary_path_no_object_change(self):
"""Freie Object-ID/Pfad ohne ObjectChange kann keinen Delete triggern."""
# Kein ObjectChange angelegt. Executor findet keinen DELETE-Change.
self.fx.store.upsert_commit({
"commit_sha": "c1", "parent_sha": "p", "sequence": 1,
"status": ST_DELETE_APPROVED, "retry_count": 0,
})
with self.assertRaises(DeleteApprovalError):
self._exec().execute("c1")
self.assertEqual(self.fx.fake.delete_count, 0)
# -- no auto-approval / no SQL bypass / no APPLIED bypass ---------------
def test_no_auto_approval(self):
"""Kein Auto-Approval: ohne CLI-approve gibt es keine Approval."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
approvals = self.fx.store.list_delete_approvals("c1")
self.assertEqual(len(approvals), 0)
with self.assertRaises(DeleteApprovalError):
self._exec().execute("c1")
def test_no_direct_applied_bypass(self):
"""Kein direkter APPLIED-Bypass: Executor setzt nie APPLIED."""
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
_approve(self.fx.store, "c1", cid, UUID_A, "a.md")
self.fx.store.transition_commit("c1", ST_DELETE_APPROVED)
r = self._exec().execute("c1")
self.assertEqual(r["status"], ST_UPDATING_SEARCH)
self.assertNotEqual(self.fx.store.commit_status("c1"), ST_APPLIED)
def test_delete_stays_human_gated(self):
"""DELETE bleibt HUMAN_GATED: neuer DELETE-Request -> HUMAN_REVIEW_REQUIRED."""
# C5B klassifiziert DELETE -> HUMAN_REVIEW_REQUIRED (kein Auto-Approval).
# Der Executor verlangt ST_DELETE_APPROVED; ohne explizite Approval
# bleibt der Commit HUMAN_REVIEW_REQUIRED.
cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md")
self.assertEqual(self.fx.store.commit_status("c1"), ST_HUMAN_REVIEW_REQUIRED)
with self.assertRaises(DeleteExecutionError):
self._exec().execute("c1")
self.assertEqual(self.fx.fake.delete_count, 0)
if __name__ == "__main__":
unittest.main(verbosity=2)