- 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)
420 lines
17 KiB
Python
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)
|