- 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)
399 lines
17 KiB
Python
399 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Red Queen — C5 DELETE EXECUTION ENGINE (DeleteExecutor).
|
|
|
|
Schliesst den DELETE_EXECUTION_CONTRACT_GAP: kontrollierter Tolaria-DELETE-Pfad
|
|
NACH einer expliziten, persistenten Human-Approval.
|
|
|
|
ZIELARCHITEKTUR (verbindlich, C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md):
|
|
DELETE erkannt (C5B) -> HUMAN_REVIEW_REQUIRED (C5A/C5C, RC_AMBIGUOUS_DELETE)
|
|
-> explizite Human Approval (CLI approve-delete, persistiert in C5AStore)
|
|
-> kontrollierter Tolaria DELETE (dieser Executor)
|
|
-> Read-Back-Verifikation (target absent + kein anderes Objekt veraendert)
|
|
-> C5D Source Build -> Search Update -> Exact-Set-Verifikation -> APPLIED
|
|
|
|
INVARIANTEN (A-N, verbindlich):
|
|
A) DELETE bleibt HUMAN_GATED.
|
|
B) Kein LLM/Agent entscheidet, dass ein DELETE genehmigt ist.
|
|
C) Approval stammt explizit vom Human Gate (CLI approve-delete).
|
|
D) Approval ist commit- und objectchange-spezifisch (keine globale
|
|
DELETE_APPROVED=true-Semantik).
|
|
E) Approval fuer Objekt A autorisiert nie Objekt B.
|
|
F) Approval fuer Commit X autorisiert nie DELETE aus Commit Y.
|
|
G) Approval ist persistent und crash-/restart-fest (SQLite in C5AStore).
|
|
H) Execution ist idempotent (DELETE_ALREADY_AT_TARGET statt zweitem Write).
|
|
I) DELETE betrifft ausschliesslich das exakt freigegebene Objekt.
|
|
J) Kein Pfad nimmt beliebige freie Paths/IDs ohne ObjectChange + Approval.
|
|
K) Tolaria DELETE wird vor und nach Execution eindeutig verifiziert.
|
|
L) Keine manuelle SQLite-/SQL-State-Manipulation (nur Store-Methoden).
|
|
M) Keine direkte APPLIED-Markierung (nur C5D nach vollem Search-PASS).
|
|
N) Keine automatische Human-Gate-Deaktivierung.
|
|
|
|
NO-MUTATING-PROBE-REGEL (Incident-Lesson): Dieser Executor fuehrt KEINE
|
|
produktive Endpoint-Probe aus. Der Endpoint-/Payload-Contract (POST /delete
|
|
mit {"path": ...}) ist aus vorhandenem Tolaria-Code/Router und dokumentierter
|
|
Incident-Evidence bestimmt.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from rq_c5a import (
|
|
C5AStore,
|
|
ST_HUMAN_REVIEW_REQUIRED,
|
|
ST_DELETE_APPROVED,
|
|
ST_DELETING,
|
|
ST_UPDATING_SEARCH,
|
|
RC_DELETE_APPROVAL_MISSING,
|
|
RC_DELETE_APPROVAL_MISMATCH,
|
|
RC_UNEXPECTED_TOLARIA_DRIFT,
|
|
OP_DELETE_REQUEST,
|
|
)
|
|
from rq_c5c import (
|
|
TolariaClient,
|
|
C5CError,
|
|
TolariaWriteError,
|
|
TolariaUnavailableError,
|
|
VAULT_PREFIX,
|
|
)
|
|
|
|
# Idempotenz-Ergebnis: Zielobjekt ist bereits korrekt absent -> kein zweiter
|
|
# destruktiver Write (Invariante H).
|
|
DELETE_ALREADY_AT_TARGET = "DELETE_ALREADY_AT_TARGET"
|
|
|
|
|
|
class DeleteExecutionError(C5CError):
|
|
"""Fehler im C5 DELETE-Execution-Pfad (FAIL CLOSED)."""
|
|
|
|
|
|
class DeleteApprovalError(DeleteExecutionError):
|
|
"""Approval fehlt oder passt nicht exakt (Invarianten D/E/F)."""
|
|
|
|
|
|
class DeleteExecutor:
|
|
"""
|
|
Fuehrt einen kontrollierten Tolaria-DELETE fuer einen ObjectChange aus,
|
|
der eine persistierte, exakt passende Human-Approval besitzt.
|
|
|
|
NUR ueber den CLI-Befehl `c5-delete execute` (bzw. `replay`) aufrufbar.
|
|
Kein automatischer DELETE. Kein freier Pfad-Parameter.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
store: C5AStore,
|
|
client: Optional[TolariaClient] = None,
|
|
):
|
|
self.store = store
|
|
self.client = client or TolariaClient()
|
|
|
|
# -- Pre-Delete-Gates (alle MÜSSEN passen, sonst FAIL CLOSED) -----------
|
|
|
|
def _find_delete_change(self, commit_sha: str) -> Dict[str, Any]:
|
|
"""Findet den DELETE-ObjectChange eines Commits. Genau einer erwartet."""
|
|
changes = self.store.list_object_changes(commit_sha)
|
|
deletes = [c for c in changes if c.get("operation") == OP_DELETE_REQUEST]
|
|
if len(deletes) != 1:
|
|
raise DeleteApprovalError(
|
|
f"Erwarte genau einen DELETE-ObjectChange in {commit_sha}, "
|
|
f"gefunden: {len(deletes)}",
|
|
RC_DELETE_APPROVAL_MISMATCH,
|
|
)
|
|
return deletes[0]
|
|
|
|
def _validate_approval(
|
|
self, commit_sha: str, change: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Validiert, dass eine persistierte Approval exakt zu diesem ObjectChange
|
|
passt (Invarianten D/E/F). Gibt die Approval zurueck oder wirft
|
|
DeleteApprovalError (FAIL CLOSED).
|
|
"""
|
|
change_id = change.get("id")
|
|
oid = change.get("object_id")
|
|
path = change.get("path_before") or change.get("path_after")
|
|
if change_id is None:
|
|
raise DeleteApprovalError(
|
|
f"DELETE-ObjectChange in {commit_sha} hat keine id",
|
|
RC_DELETE_APPROVAL_MISMATCH,
|
|
)
|
|
|
|
approval = self.store.get_delete_approval_for_change(commit_sha, change_id)
|
|
if approval is None:
|
|
raise DeleteApprovalError(
|
|
f"Keine persistierte Human-Approval fuer ObjectChange {change_id} "
|
|
f"in Commit {commit_sha}",
|
|
RC_DELETE_APPROVAL_MISSING,
|
|
)
|
|
if approval.get("approval_status") != "APPROVED":
|
|
raise DeleteApprovalError(
|
|
f"Approval {approval.get('approval_id')} ist nicht APPROVED "
|
|
f"(Status: {approval.get('approval_status')})",
|
|
RC_DELETE_APPROVAL_MISMATCH,
|
|
)
|
|
# Invariante F: Approval bindet exakt diesen Commit.
|
|
if approval.get("workflow_commit") != commit_sha:
|
|
raise DeleteApprovalError(
|
|
f"Approval {approval.get('approval_id')} bindet Commit "
|
|
f"{approval.get('workflow_commit')}, nicht {commit_sha}",
|
|
RC_DELETE_APPROVAL_MISMATCH,
|
|
)
|
|
# Invariante E: Approval bindet exakt dieses Objekt.
|
|
if approval.get("object_id") != oid:
|
|
raise DeleteApprovalError(
|
|
f"Approval {approval.get('approval_id')} bindet Objekt "
|
|
f"{approval.get('object_id')}, nicht {oid}",
|
|
RC_DELETE_APPROVAL_MISMATCH,
|
|
)
|
|
# Invariante I: Approval bindet exakt diesen Pfad.
|
|
if approval.get("path") != path:
|
|
raise DeleteApprovalError(
|
|
f"Approval {approval.get('approval_id')} bindet Pfad "
|
|
f"{approval.get('path')}, nicht {path}",
|
|
RC_DELETE_APPROVAL_MISMATCH,
|
|
)
|
|
return approval
|
|
|
|
def _pre_delete_drift_check(self, vault_path: str) -> None:
|
|
"""
|
|
Invariante K (vor): Das Zielobjekt MUSS in Tolaria noch existieren,
|
|
sonst ist der erwartete aktuelle Zustand verletzt (FAIL CLOSED).
|
|
"""
|
|
current = self.client.read(vault_path)
|
|
if current is None:
|
|
# Objekt bereits absent -> kein blinder zweiter destruktiver Write.
|
|
# Der Executor erkennt DELETE_ALREADY_AT_TARGET (Idempotenz, H).
|
|
raise DeleteExecutionError(
|
|
f"Zielobjekt {vault_path} ist bereits absent (DELETE_ALREADY_AT_TARGET)",
|
|
RC_UNEXPECTED_TOLARIA_DRIFT,
|
|
)
|
|
|
|
def _read_back_absent(self, vault_path: str) -> bool:
|
|
"""Invariante K (nach): Read-Back -> target absent == TRUE."""
|
|
return self.client.read(vault_path) is None
|
|
|
|
def _verify_no_other_change(self, before_paths: set, after_paths: set) -> None:
|
|
"""
|
|
Stellt sicher, dass ausschliesslich das freigegebene Objekt entfernt
|
|
wurde (kein anderes Objekt veraendert). before/after sind die Pfad-Sets
|
|
aus list().
|
|
"""
|
|
removed = before_paths - after_paths
|
|
added = after_paths - before_paths
|
|
if added:
|
|
raise DeleteExecutionError(
|
|
f"Unerwartete neue Objekte nach DELETE: {sorted(added)}",
|
|
RC_UNEXPECTED_TOLARIA_DRIFT,
|
|
)
|
|
|
|
# -- Execution ----------------------------------------------------------
|
|
|
|
def execute(self, commit_sha: str) -> Dict[str, Any]:
|
|
"""
|
|
Fuehrt den kontrollierten DELETE fuer einen Commit aus.
|
|
|
|
Pre-Gates (alle MÜSSEN passen, sonst FAIL CLOSED):
|
|
1. ObjectChange existiert, operation == DELETE
|
|
2. Commit-Status == ST_DELETE_APPROVED
|
|
3. Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad)
|
|
4. Pre-Delete-Drift-Check: Ziel existiert noch
|
|
|
|
Nach DELETE:
|
|
- Read-Back: target absent == TRUE
|
|
- kein anderes Objekt veraendert
|
|
- Approval -> USED (einmalig)
|
|
- Commit -> ST_UPDATING_SEARCH (C5D uebernimmt Search-Pfad)
|
|
|
|
Idempotenz (H): Wenn das Ziel bereits absent ist, wird KEIN zweiter
|
|
destruktiver Write erzeugt; der Executor meldet DELETE_ALREADY_AT_TARGET
|
|
und geht trotzdem zu ST_UPDATING_SEARCH (Zielzustand ist erreicht).
|
|
"""
|
|
# Gate 1: ObjectChange existiert + operation == DELETE
|
|
change = self._find_delete_change(commit_sha)
|
|
oid = change.get("object_id")
|
|
path = change.get("path_before") or change.get("path_after")
|
|
if not path:
|
|
raise DeleteApprovalError(
|
|
f"DELETE-ObjectChange {change.get('id')} hat keinen Pfad",
|
|
RC_DELETE_APPROVAL_MISMATCH,
|
|
)
|
|
vault_path = self._normalize_vault_path(path)
|
|
|
|
# Gate 2: Commit-Status == ST_DELETE_APPROVED
|
|
status = self.store.commit_status(commit_sha)
|
|
if status != ST_DELETE_APPROVED:
|
|
raise DeleteExecutionError(
|
|
f"Commit {commit_sha} ist nicht DELETE_APPROVED (Status: {status})",
|
|
RC_DELETE_APPROVAL_MISMATCH,
|
|
)
|
|
|
|
# Gate 3: Approval existiert + passt exakt
|
|
approval = self._validate_approval(commit_sha, change)
|
|
|
|
# Gate 4: Pre-Delete-Drift-Check (Ziel existiert noch)
|
|
try:
|
|
self._pre_delete_drift_check(vault_path)
|
|
except DeleteExecutionError as e:
|
|
if "DELETE_ALREADY_AT_TARGET" in str(e):
|
|
# Idempotenz: Ziel bereits korrekt absent. Kein zweiter Write.
|
|
self.store.mark_delete_approval_used(approval["approval_id"])
|
|
self.store.transition_commit(commit_sha, ST_UPDATING_SEARCH)
|
|
return {
|
|
"commit_sha": commit_sha,
|
|
"object_id": oid,
|
|
"path": path,
|
|
"status": ST_UPDATING_SEARCH,
|
|
"idempotency": DELETE_ALREADY_AT_TARGET,
|
|
"deleted": False,
|
|
}
|
|
raise
|
|
|
|
return self._do_delete(commit_sha, change, approval, vault_path, oid, path)
|
|
|
|
def _do_delete(
|
|
self, commit_sha: str, change: Dict[str, Any], approval: Dict[str, Any],
|
|
vault_path: str, oid: Optional[str], path: str,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Kern der DELETE-Ausfuehrung (von execute() und replay()/ST_DELETING genutzt).
|
|
|
|
Erwartet: Approval validiert, Ziel existiert noch, Commit in
|
|
ST_DELETE_APPROVED oder ST_DELETING. Fuehrt den kontrollierten DELETE aus,
|
|
Read-Back, kein anderes Objekt veraendert, Approval -> USED,
|
|
Commit -> ST_UPDATING_SEARCH.
|
|
"""
|
|
# State: -> ST_DELETING (Crash-Window). Idempotent: wenn bereits
|
|
# ST_DELETING (Replay nach Crash), keine erneute Transition noetig.
|
|
if self.store.commit_status(commit_sha) != ST_DELETING:
|
|
self.store.transition_commit(commit_sha, ST_DELETING)
|
|
|
|
# Vorheriges Pfad-Set (fuer "kein anderes Objekt veraendert")
|
|
before_paths = {o.get("path") for o in self.client.list(VAULT_PREFIX)}
|
|
|
|
# Execution: kontrollierter Tolaria DELETE
|
|
try:
|
|
self.client.delete(vault_path)
|
|
except (TolariaWriteError, TolariaUnavailableError) as e:
|
|
# Fehler waehrend Execution -> zurueck zum Human Gate (FAIL CLOSED)
|
|
self.store.set_commit_error(commit_sha, e.reason_code or RC_UNEXPECTED_TOLARIA_DRIFT, e.message)
|
|
self.store.transition_commit(commit_sha, ST_HUMAN_REVIEW_REQUIRED)
|
|
raise DeleteExecutionError(
|
|
f"Tolaria DELETE fehlgeschlagen fuer {vault_path}: {e.message}",
|
|
e.reason_code,
|
|
)
|
|
|
|
# Read-Back: target absent == TRUE (Invariante K nach)
|
|
if not self._read_back_absent(vault_path):
|
|
self.store.set_commit_error(
|
|
commit_sha, RC_UNEXPECTED_TOLARIA_DRIFT,
|
|
f"Read-Back nach DELETE: {vault_path} ist nicht absent")
|
|
self.store.transition_commit(commit_sha, ST_HUMAN_REVIEW_REQUIRED)
|
|
raise DeleteExecutionError(
|
|
f"Read-Back nach DELETE: {vault_path} ist nicht absent",
|
|
RC_UNEXPECTED_TOLARIA_DRIFT,
|
|
)
|
|
|
|
# Kein anderes Objekt veraendert (FAIL CLOSED bei Drift)
|
|
after_paths = {o.get("path") for o in self.client.list(VAULT_PREFIX)}
|
|
try:
|
|
self._verify_no_other_change(before_paths, after_paths)
|
|
except DeleteExecutionError as e:
|
|
# Drift nach DELETE -> zurueck zum Human Gate (FAIL CLOSED),
|
|
# NICHT in ST_DELETING haengen lassen.
|
|
self.store.set_commit_error(
|
|
commit_sha, e.reason_code or RC_UNEXPECTED_TOLARIA_DRIFT, e.message)
|
|
self.store.transition_commit(commit_sha, ST_HUMAN_REVIEW_REQUIRED)
|
|
raise
|
|
|
|
# Approval -> USED (einmalig, verhindert Reuse)
|
|
self.store.mark_delete_approval_used(approval["approval_id"])
|
|
|
|
# Commit -> ST_UPDATING_SEARCH (C5D uebernimmt Search-Pfad)
|
|
self.store.transition_commit(commit_sha, ST_UPDATING_SEARCH)
|
|
|
|
return {
|
|
"commit_sha": commit_sha,
|
|
"object_id": oid,
|
|
"path": path,
|
|
"status": ST_UPDATING_SEARCH,
|
|
"idempotency": "EXECUTED",
|
|
"deleted": True,
|
|
}
|
|
|
|
def replay(self, commit_sha: str) -> Dict[str, Any]:
|
|
"""
|
|
Idempotenter Replay nach Crash (Crash-Faelle B/C/D).
|
|
|
|
- Commit in ST_DELETE_APPROVED: Approval persistiert, DELETE noch nicht
|
|
ausgefuehrt -> normaler execute().
|
|
- Commit in ST_DELETING: DELETE evtl. schon ausgefuehrt -> Read-Back.
|
|
Objekt absent -> DELETE_ALREADY_AT_TARGET, weiter zu ST_UPDATING_SEARCH.
|
|
Objekt noch da -> DELETE erneut (Approval gueltig).
|
|
- Commit in ST_UPDATING_SEARCH: bereits nach DELETE -> nichts zu tun.
|
|
"""
|
|
status = self.store.commit_status(commit_sha)
|
|
if status == ST_UPDATING_SEARCH:
|
|
return {
|
|
"commit_sha": commit_sha,
|
|
"status": ST_UPDATING_SEARCH,
|
|
"idempotency": "ALREADY_AT_TARGET",
|
|
"deleted": False,
|
|
}
|
|
if status == ST_DELETING:
|
|
# Crash waehrend DELETE (Fall C/D): Read-Back entscheidet.
|
|
change = self._find_delete_change(commit_sha)
|
|
path = change.get("path_before") or change.get("path_after")
|
|
if not path:
|
|
raise DeleteApprovalError(
|
|
f"DELETE-ObjectChange {change.get('id')} hat keinen Pfad",
|
|
RC_DELETE_APPROVAL_MISMATCH,
|
|
)
|
|
vault_path = self._normalize_vault_path(path)
|
|
if self._read_back_absent(vault_path):
|
|
# DELETE war bereits erfolgreich -> Zielzustand erreicht.
|
|
change_id = change.get("id")
|
|
approval = None
|
|
if change_id is not None:
|
|
approval = self.store.get_delete_approval_for_change(
|
|
commit_sha, change_id)
|
|
if approval and approval.get("approval_status") == "APPROVED":
|
|
self.store.mark_delete_approval_used(approval["approval_id"])
|
|
self.store.transition_commit(commit_sha, ST_UPDATING_SEARCH)
|
|
return {
|
|
"commit_sha": commit_sha,
|
|
"object_id": change.get("object_id"),
|
|
"path": path,
|
|
"status": ST_UPDATING_SEARCH,
|
|
"idempotency": DELETE_ALREADY_AT_TARGET,
|
|
"deleted": False,
|
|
}
|
|
# Objekt noch da -> DELETE erneut. Approval validieren + _do_delete.
|
|
approval = self._validate_approval(commit_sha, change)
|
|
return self._do_delete(
|
|
commit_sha, change, approval, vault_path,
|
|
change.get("object_id"), path)
|
|
# ST_DELETE_APPROVED (Fall B) oder sonstiger Zustand -> normaler execute
|
|
return self.execute(commit_sha)
|
|
|
|
@staticmethod
|
|
def _normalize_vault_path(path: str) -> str:
|
|
"""
|
|
Normalisiert einen Vault-Pfad unter VAULT_PREFIX (kein Traversal).
|
|
|
|
SECURITY (Todo 11): Blockiert `..`-Traversal-Segmente und absolute
|
|
Pfade ausserhalb des Vaults. Der Pfad stammt zwar aus dem ObjectChange
|
|
(nicht frei), aber Defense-in-Depth verhindert, dass ein manipulierter
|
|
ObjectChange-Pfad ein beliebiges Dateisystem-Ziel loeschen kann.
|
|
"""
|
|
p = path.replace("\\", "/").lstrip("/")
|
|
# Traversal-Segmente hart blockieren (FAIL CLOSED).
|
|
segments = [s for s in p.split("/") if s not in ("", ".")]
|
|
if any(s == ".." for s in segments):
|
|
raise DeleteExecutionError(
|
|
f"Pfad-Traversal blockiert: {path!r}",
|
|
RC_DELETE_APPROVAL_MISMATCH,
|
|
)
|
|
if p.startswith(VAULT_PREFIX.lstrip("/")):
|
|
return "/" + p
|
|
return f"{VAULT_PREFIX}/{p}"
|