AUTH.3E: Executor Command Channel + Runtime Boundary Contract

- job_schema: geschlossene Job-Type-Allowlist (C5_SAVE_OBJECT/C5_DELETE_OBJECT), Pfad-/Längen-Validierung
- job_state_machine: deterministische States (CREATED/READY/CLAIMED/EXECUTING/SUCCEEDED/FAILED)
- job_claim: atomare Claim-/Lease-/Recovery-Logik (kein TOCTOU)
- job_store: getrennte SQLite-Inbox-DBs (c5a_save.db/c5a_delete.db), delegiert an job_claim
- save_executor_core: SAVE-only, Content-Rekonstruktion, Provenance-Validierung
- delete_executor_core: DELETE-only, AUTH.3D-Composition, TOCTOU-Defense (Re-Read nach Claim)
- test_job_channel: T1-T40 + adversarial (72 Tests)
- test_job_channel_adversarial: adversarial + Substitution + DB-Manipulation
- sensitivity_proof_auth3e: Mutationen A-L (12/12 Invarianten PRESENT)
- AUTH3B/3C/3D/3E_DESIGN: autoritative Security-Dokumentation (e25 Reconciliation)

COMMAND != AUTHORIZATION. Kein generischer Dispatcher. RQ credential-free.
Keine produktive Mutation. Keine echten Credentials.
This commit is contained in:
Red Queen 2026-08-27 10:03:32 +00:00
parent 9c8d239ae5
commit 10761f52b2
13 changed files with 4990 additions and 0 deletions

View file

@ -0,0 +1,186 @@
"""
AUTH.3E delete_executor_core.py
==================================
DELETE-Executor-Logik (DELETE-only, AUTH.3D-Validierung).
Eigenschaften:
* DELETE-only: claimt NUR C5_DELETE_OBJECT-Jobs
* Der DELETE-Job selbst ist KEINE Approval
* AUTH.3D-Composition: VALID_JOB AND VALID_STATE AND VALID_AUTH3D_SIGNATURE
AND VALID_NONCE AND VALID_EXPIRY AND NOT_CONSUMED AND VALID_OBJECT AND
VALID_PATH AND VALID_COMMIT AND VALID_PROVENANCE AND DELETE_CREDENTIAL
* Kein Faktor ersetzt einen anderen
* Fail-closed: kein HTTP ohne DELETE-Credential
* OUTCOME_UNKNOWN bei unklarem HTTP-Ergebnis (kein blinder Retry)
* Ein gestarteter DELETE wird bei Lease-Expiry NICHT blind wiederholt
Isoliert implementiert (KEIN produktiver Container). Nutzt job_store + job_schema.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
from job_schema import JOB_TYPE_DELETE
from job_store import JobStore
# ---------------------------------------------------------------------------
# Result-Codes
# ---------------------------------------------------------------------------
RC_OK = "OK"
RC_CREDENTIAL_MISSING = "CREDENTIAL_MISSING"
RC_APPROVAL_MISSING = "APPROVAL_MISSING"
RC_APPROVAL_INVALID = "APPROVAL_INVALID"
RC_APPROVAL_EXPIRED = "APPROVAL_EXPIRED"
RC_APPROVAL_CONSUMED = "APPROVAL_CONSUMED"
RC_APPROVAL_MISMATCH = "APPROVAL_MISMATCH"
RC_OBJECT_MISMATCH = "OBJECT_MISMATCH"
RC_PATH_MISMATCH = "PATH_MISMATCH"
RC_COMMIT_MISMATCH = "COMMIT_MISMATCH"
RC_PROVENANCE_MISMATCH = "PROVENANCE_MISMATCH"
RC_MISSION_MISMATCH = "MISSION_MISMATCH"
RC_DELETE_DENIED = "DELETE_DENIED"
RC_OUTCOME_UNKNOWN = "OUTCOME_UNKNOWN"
RC_REJECTED = "REJECTED"
class DeleteExecutorError(Exception):
pass
class DeleteExecutorCore:
"""
DELETE-Executor. worker_scope="DELETE" (nur DELETE-Jobs claimbar).
approval_loader: Callable[[str], Optional[Dict[str, Any]]] lädt die
AUTH.3D-Approval (approval_id) -> dict oder None.
approval_verify: Callable[[Dict[str, Any]], Dict[str, Any]] verifiziert die
AUTH.3D-Signatur (Ed25519 PUBLIC KEY ONLY) -> {"valid": bool, "reason": str}.
tolaria_delete: Callable[[str], Dict[str, Any]] führt den Tolaria-DELETE aus
(vault_path) -> {"status": "ok"|"error", "uncertain": bool}.
Muss fail-closed sein (kein HTTP ohne Credential).
"""
def __init__(
self,
store: JobStore,
approval_loader: Callable[[str], Optional[Dict[str, Any]]],
approval_verify: Callable[[Dict[str, Any]], Dict[str, Any]],
tolaria_delete: Callable[[str], Dict[str, Any]],
):
if store.worker_scope != "DELETE":
raise DeleteExecutorError("DeleteExecutorCore requires worker_scope='DELETE'")
self.store = store
self.approval_loader = approval_loader
self.approval_verify = approval_verify
self.tolaria_delete = tolaria_delete
# -- Hauptverarbeitung --------------------------------------------------
def process_job(self, job_id: str, worker_id: str) -> Dict[str, Any]:
"""
Verarbeitet einen DELETE-Job durch die State Machine.
Ablauf:
1. Job laden (muss existieren)
2. Job-Type-Scope prüfen (nur DELETE)
3. READY -> CLAIMED (atomarer Claim)
4. CLAIMED -> EXECUTING
5. AUTH.3D-Composition validieren (Job + Approval exakt zusammenpassen)
6. Tolaria-DELETE ausführen
7. Ergebnis: SUCCEEDED / FAILED / OUTCOME_UNKNOWN / REJECTED
"""
job = self.store.get_job(job_id)
if job is None:
raise DeleteExecutorError(f"job not found: {job_id}")
# Scope: nur DELETE-Jobs
if job["job_type"] != JOB_TYPE_DELETE:
self.store.mark_rejected(job_id, RC_REJECTED)
return self.store.get_job(job_id)
# Atomarer Claim
try:
self.store.claim_job(job_id, worker_id)
except Exception:
# Nicht claimbar (bereits geclaimt) -> kein Doppel-Delete
return self.store.get_job(job_id)
self.store.begin_execution(job_id, worker_id)
# AUTH.3D-Composition (alle Faktoren, keiner ersetzt einen anderen).
# Job NACH dem Claim neu lesen, damit eine Payload-Manipulation nach
# dem Claim (TOCTOU) erkannt wird — Defense in depth.
job = self.store.get_job(job_id)
if job is None:
self.store.mark_failed(job_id, RC_DELETE_DENIED, worker_id)
return self.store.get_job(job_id)
gate = self._validate_auth3d_composition(job)
if gate["valid"] is not True:
self.store.mark_failed(job_id, gate["code"], worker_id)
return self.store.get_job(job_id)
# Tolaria-DELETE ausführen (fail-closed)
result = self.tolaria_delete(job["vault_path"])
status = result.get("status")
if status == "ok":
self.store.mark_succeeded(job_id, worker_id)
elif status == "error" and result.get("uncertain"):
self.store.mark_outcome_unknown(job_id, worker_id)
else:
self.store.mark_failed(job_id, result.get("code", RC_DELETE_DENIED), worker_id)
return self.store.get_job(job_id)
# -- AUTH.3D-Composition -------------------------------------------------
def _validate_auth3d_composition(self, job: Dict[str, Any]) -> Dict[str, Any]:
"""
Validiert die AUTH.3D-Composition. Alle Faktoren müssen gelten:
VALID_JOB AND VALID_STATE AND VALID_AUTH3D_SIGNATURE AND VALID_NONCE
AND VALID_EXPIRY AND NOT_CONSUMED AND VALID_OBJECT AND VALID_PATH
AND VALID_COMMIT AND VALID_PROVENANCE AND DELETE_CREDENTIAL
Rückgabe: {"valid": True} oder {"valid": False, "code": RC_...}
"""
# VALID_JOB (Schema bereits beim Erzeugen validiert)
# VALID_STATE (EXECUTING — bereits durch begin_execution)
# Approval laden
approval = self.approval_loader(job["approval_id"])
if approval is None:
return {"valid": False, "code": RC_APPROVAL_MISSING}
# VALID_AUTH3D_SIGNATURE (Ed25519 PUBLIC KEY ONLY)
sig = self.approval_verify(approval)
if sig.get("valid") is not True:
return {"valid": False, "code": RC_APPROVAL_INVALID}
# VALID_NONCE / VALID_EXPIRY / NOT_CONSUMED
if approval.get("expired"):
return {"valid": False, "code": RC_APPROVAL_EXPIRED}
if approval.get("consumed"):
return {"valid": False, "code": RC_APPROVAL_CONSUMED}
# VALID_OBJECT
if approval.get("object_id") != job["object_id"]:
return {"valid": False, "code": RC_OBJECT_MISMATCH}
# VALID_PATH
if approval.get("vault_path") != job["vault_path"]:
return {"valid": False, "code": RC_PATH_MISMATCH}
# VALID_COMMIT
if approval.get("expected_commit") != job["expected_commit"]:
return {"valid": False, "code": RC_COMMIT_MISMATCH}
# VALID_PROVENANCE
if approval.get("expected_provenance_hash") != job["expected_provenance_hash"]:
return {"valid": False, "code": RC_PROVENANCE_MISMATCH}
# VALID_MISSION
if approval.get("mission_id") != job["mission_id"]:
return {"valid": False, "code": RC_MISSION_MISMATCH}
# DELETE_CREDENTIAL (fail-closed — tolaria_delete prüft selbst)
return {"valid": True}

View file

@ -0,0 +1,149 @@
"""
AUTH.3E job_claim.py
======================
Atomarer Claim / Lease / Concurrency für den Executor Command Channel.
Garantien:
* Claim ist ATOMAR (SQLite-Transaktion mit Status-Bedingung) kein TOCTOU.
* Zwei SAVE-Worker: nur einer kann denselben Job claimen.
* Zwei DELETE-Worker: nur einer kann denselben DELETE-Job claimen.
* Ein bereits gestarteter DELETE wird bei Lease-Expiry NICHT blind wiederholt
(-> OUTCOME_UNKNOWN, nicht zurück zu READY).
* Stale Claims werden via Lease-Expiry erkannt.
Isoliert implementiert (KEIN produktiver Container).
"""
from __future__ import annotations
import sqlite3
import time
import uuid
from typing import Any, Dict, Optional
from job_state_machine import ST_CLAIMED, ST_READY
# ---------------------------------------------------------------------------
# Fehler
# ---------------------------------------------------------------------------
class ClaimError(Exception):
"""Basis-Fehler für Claim/Lease."""
class JobAlreadyClaimedError(ClaimError):
"""Job ist nicht claimbar (bereits geclaimt oder Lease aktiv)."""
class JobNotFoundError(ClaimError):
pass
# ---------------------------------------------------------------------------
# Atomarer Claim
# ---------------------------------------------------------------------------
def atomic_claim(
conn: sqlite3.Connection,
job_id: str,
worker_id: str,
lease_seconds: int = 60,
) -> Dict[str, Any]:
"""
Führt einen atomaren Claim aus: READY -> CLAIMED, nur wenn Lease abgelaufen
oder nie gesetzt. Kein TOCTOU (Status-Bedingung in der UPDATE-WHERE-Klausel).
Rückgabe: der geclaimte Job (als dict).
Wirft JobAlreadyClaimedError, wenn der Job nicht claimbar ist.
"""
now = int(time.time() * 1000)
lease_until = now + lease_seconds * 1000
claim_id = str(uuid.uuid4())
try:
cur = conn.execute(
"""
UPDATE jobs
SET state = ?, worker_id = ?, claim_id = ?, claimed_at = ?,
lease_until = ?, attempt_count = attempt_count + 1, updated_at = ?
WHERE job_id = ?
AND state = ?
AND (lease_until IS NULL OR lease_until < ?)
""",
(ST_CLAIMED, worker_id, claim_id, now, lease_until, now,
job_id, ST_READY, now),
)
conn.commit()
except sqlite3.IntegrityError as e:
raise ClaimError(f"atomic_claim failed: {e}") from e
if cur.rowcount == 0:
raise JobAlreadyClaimedError(f"job not claimable: {job_id}")
row = conn.execute(
"SELECT * FROM jobs WHERE job_id = ?", (job_id,)
).fetchone()
if row is None:
raise JobNotFoundError(f"job not found after claim: {job_id}")
return dict(row)
# ---------------------------------------------------------------------------
# Lease
# ---------------------------------------------------------------------------
def renew_lease(
conn: sqlite3.Connection,
job_id: str,
lease_seconds: int = 60,
) -> None:
"""Verlängert die Lease eines CLAIMED/EXECUTING-Jobs."""
now = int(time.time() * 1000)
lease_until = now + lease_seconds * 1000
conn.execute(
"UPDATE jobs SET lease_until = ?, updated_at = ? WHERE job_id = ?",
(lease_until, now, job_id),
)
conn.commit()
def is_lease_expired(lease_until: Optional[int], now: Optional[int] = None) -> bool:
"""True, wenn die Lease abgelaufen ist (oder nie gesetzt)."""
if lease_until is None:
return True
now = now if now is not None else int(time.time() * 1000)
return lease_until < now
# ---------------------------------------------------------------------------
# Crash-Recovery
# ---------------------------------------------------------------------------
def recover_stale_claims(
conn: sqlite3.Connection,
job_type: str,
now: Optional[int] = None,
) -> int:
"""
Findet stale CLAIMED-Jobs (Lease abgelaufen) und überführt sie:
* SAVE: zurück zu READY (wieder claimbar, RETRYABLE)
* DELETE: zu OUTCOME_UNKNOWN (NICHT blind wiederholen)
Rückgabe: Anzahl der recovered Jobs.
"""
from job_state_machine import ST_OUTCOME_UNKNOWN
now = now if now is not None else int(time.time() * 1000)
stale = conn.execute(
"SELECT job_id FROM jobs WHERE state = ? AND lease_until IS NOT NULL AND lease_until < ?",
(ST_CLAIMED, now),
).fetchall()
recovered = 0
for row in stale:
job_id = row["job_id"]
if job_type == "C5_SAVE_OBJECT":
conn.execute(
"UPDATE jobs SET state = ?, updated_at = ? WHERE job_id = ?",
(ST_READY, now, job_id),
)
else:
conn.execute(
"UPDATE jobs SET state = ?, updated_at = ? WHERE job_id = ?",
(ST_OUTCOME_UNKNOWN, now, job_id),
)
recovered += 1
conn.commit()
return recovered

View file

@ -0,0 +1,290 @@
"""
AUTH.3E job_schema.py
=======================
Geschlossene Job-Schema-Validierung für den Executor Command Channel.
Eigenschaften:
* job_type als geschlossene Allowlist (Enum): C5_SAVE_OBJECT | C5_DELETE_OBJECT
* Typisierte Felder (uuid, sha256-hex, ISO8601-UTC, enum)
* KEIN generischer Dispatcher: keine command=/endpoint=/url=/method=/shell=/
python=/handler=-Felder erlaubt
* Unknown job_type -> REJECTED
* Extra privileged fields -> REJECTED
* Malformed schema -> REJECTED
* Command-Injection-Defense: Jobs dürfen niemals shell/executable/python/url/
headers/credentials/sql/docker/ssh enthalten
Isoliert implementiert (KEIN produktiver Container). Wiederverwendet die
AUTH.3D-Payload-Normalisierung (approval_payload._normalize_path) für Pfade.
"""
from __future__ import annotations
import re
import uuid
from typing import Any, Dict, List, Optional
# ---------------------------------------------------------------------------
# Geschlossene Job-Type-Allowlist
# ---------------------------------------------------------------------------
JOB_TYPE_SAVE = "C5_SAVE_OBJECT"
JOB_TYPE_DELETE = "C5_DELETE_OBJECT"
JOB_TYPES = frozenset({JOB_TYPE_SAVE, JOB_TYPE_DELETE})
JOB_VERSION = 1
# ---------------------------------------------------------------------------
# Verbotene privilegierte Felder (Command-Injection-Defense)
# ---------------------------------------------------------------------------
# Diese Felder dürfen in KEINEM Job vorkommen. Ihr Vorhandensein -> REJECTED.
FORBIDDEN_FIELDS = frozenset({
"command", "endpoint", "url", "method", "shell", "python", "handler",
"exec", "executable", "script", "headers", "authorization", "credential",
"token", "password", "secret", "sql", "query", "docker", "ssh",
"base_url", "http_method", "auth_header",
})
# ---------------------------------------------------------------------------
# Erlaubte Felder je Job-Type (geschlossene Schemata)
# ---------------------------------------------------------------------------
BASE_FIELDS = frozenset({
"job_version", "job_id", "mission_id", "job_type", "object_id",
"vault_path", "created_at", "idempotency_key",
})
SAVE_FIELDS = BASE_FIELDS | frozenset({
"source_commit", "provenance_hash", "expected_state",
})
DELETE_FIELDS = BASE_FIELDS | frozenset({
"delete_request_id", "expected_commit", "expected_provenance_hash",
"approval_id",
})
ALLOWED_FIELDS = {
JOB_TYPE_SAVE: SAVE_FIELDS,
JOB_TYPE_DELETE: DELETE_FIELDS,
}
# ---------------------------------------------------------------------------
# Validierungs-Helfer
# ---------------------------------------------------------------------------
_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
_ISO8601_UTC_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
def _is_uuid(value: Any) -> bool:
return isinstance(value, str) and bool(_UUID_RE.match(value))
def _is_sha256(value: Any) -> bool:
return isinstance(value, str) and bool(_SHA256_RE.match(value))
def _is_iso8601_utc(value: Any) -> bool:
return isinstance(value, str) and bool(_ISO8601_UTC_RE.match(value))
def _is_nonempty_str(value: Any) -> bool:
return isinstance(value, str) and len(value) > 0
def _normalize_path(path: str) -> str:
"""
Normalisiert einen Vault-Pfad (keine //, ., .., trailing slash).
Wiederverwendet die AUTH.3D-Pfad-Normalisierung.
"""
try:
from approval_payload import _normalize_path as _ap_normalize
return _ap_normalize(path)
except Exception:
# Fallback (isoliert): eigene Normalisierung
parts = [p for p in path.split("/") if p not in ("", ".")]
if ".." in parts:
raise ValueError("path traversal")
return "/" + "/".join(parts)
def _validate_path(path: str) -> Optional[str]:
"""
Pfad-Sicherheitsprüfung (Executor-seitig, Defense in depth).
Erlaubt nur Pfade unterhalb des Vault-Roots, keine Traversal,
keine absoluten Host-Pfade, keine Unicode-Ambiguität, keine
URL-Encodierung, keine Sonderzeichen.
"""
if not isinstance(path, str) or not path:
return "path must be a non-empty string"
if len(path) > 1024:
return "path too long"
if "\x00" in path:
return "path contains null byte"
# Kein absoluter Host-Pfad (nur /app/vault/... erlaubt)
if not path.startswith("/app/vault/"):
return "path must be under /app/vault/"
# Zeichensatz-Whitelist: nur sichere Pfadzeichen.
# Schließt URL-Encodierung (%), Unicode-Homoglyphen, Leerzeichen,
# Steuerzeichen und Sonderzeichen aus.
if not re.fullmatch(r"[A-Za-z0-9/._-]+", path):
return "path contains disallowed characters"
# Traversal / Normalisierung
try:
norm = _normalize_path(path)
except ValueError:
return "path traversal detected"
if norm != path:
return "path not normalized"
# Unicode-Ambiguität: keine Homoglyphen-/Normalisierungs-Angriffe
import unicodedata
if unicodedata.normalize("NFC", path) != path:
return "path not NFC-normalized"
return None
# ---------------------------------------------------------------------------
# Job-Schema-Validierung
# ---------------------------------------------------------------------------
class JobSchemaError(ValueError):
"""Basis-Fehler für Schema-Validierung."""
class JobRejectedError(JobSchemaError):
"""Job wurde REJECTED (Schema/Allowlist/Injection-Fehler)."""
def validate_job(job: Dict[str, Any]) -> Dict[str, Any]:
"""
Validiert einen Job gegen das geschlossene Schema.
Rückgabe: normalisierter Job (bei Erfolg).
Wirft JobRejectedError bei:
* unknown job_type
* malformed schema
* extra privileged fields
* Command-Injection-Felder
* Pfad-Verletzungen
"""
if not isinstance(job, dict):
raise JobRejectedError("job must be a dict")
# 1. job_type muss in der geschlossenen Allowlist sein
job_type = job.get("job_type")
if not isinstance(job_type, str) or job_type not in JOB_TYPES:
raise JobRejectedError(f"unknown job_type: {job_type!r}")
# 2. job_version muss stimmen
if job.get("job_version") != JOB_VERSION:
raise JobRejectedError(f"unknown job_version: {job.get('job_version')!r}")
# 3. Keine privilegierten/verbotenen Felder
extra = set(job.keys()) - ALLOWED_FIELDS[job_type]
if extra:
raise JobRejectedError(f"extra/unknown fields: {sorted(extra)}")
forbidden = set(job.keys()) & FORBIDDEN_FIELDS
if forbidden:
raise JobRejectedError(f"forbidden privileged fields: {sorted(forbidden)}")
# 4. Pflichtfelder vorhanden
required = ALLOWED_FIELDS[job_type]
missing = required - set(job.keys())
if missing:
raise JobRejectedError(f"missing required fields: {sorted(missing)}")
# 5. Typ-Validierung
if not _is_uuid(job.get("job_id")):
raise JobRejectedError("job_id must be a UUID")
if not _is_uuid(job.get("mission_id")):
raise JobRejectedError("mission_id must be a UUID")
if not _is_uuid(job.get("idempotency_key")):
raise JobRejectedError("idempotency_key must be a UUID")
if not _is_nonempty_str(job.get("object_id")):
raise JobRejectedError("object_id must be a non-empty string")
if len(job.get("object_id", "")) > 256:
raise JobRejectedError("object_id too long")
if not _is_iso8601_utc(job.get("created_at")):
raise JobRejectedError("created_at must be ISO8601 UTC (YYYY-MM-DDTHH:MM:SSZ)")
# 6. Pfad-Sicherheit
path_err = _validate_path(job.get("vault_path"))
if path_err:
raise JobRejectedError(f"vault_path invalid: {path_err}")
# 7. Job-Type-spezifische Felder
if job_type == JOB_TYPE_SAVE:
if not _is_sha256(job.get("source_commit")):
raise JobRejectedError("source_commit must be a sha256 hex")
if not _is_sha256(job.get("provenance_hash")):
raise JobRejectedError("provenance_hash must be a sha256 hex")
if not _is_nonempty_str(job.get("expected_state")):
raise JobRejectedError("expected_state must be a non-empty string")
elif job_type == JOB_TYPE_DELETE:
if not _is_uuid(job.get("delete_request_id")):
raise JobRejectedError("delete_request_id must be a UUID")
if not _is_sha256(job.get("expected_commit")):
raise JobRejectedError("expected_commit must be a sha256 hex")
if not _is_sha256(job.get("expected_provenance_hash")):
raise JobRejectedError("expected_provenance_hash must be a sha256 hex")
if not _is_uuid(job.get("approval_id")):
raise JobRejectedError("approval_id must be a UUID")
# 8. Keine Duplicate-Keys (JSON-Duplikat-Angriff)
# (wird durch parse_payload in approval_payload abgedeckt; hier defensiv)
return dict(job)
def make_save_job(
job_id: str,
mission_id: str,
object_id: str,
vault_path: str,
source_commit: str,
provenance_hash: str,
expected_state: str,
created_at: str,
idempotency_key: str,
) -> Dict[str, Any]:
"""Erzeugt einen kanonischen SAVE-Job (validiert)."""
job = {
"job_version": JOB_VERSION,
"job_id": job_id,
"mission_id": mission_id,
"job_type": JOB_TYPE_SAVE,
"object_id": object_id,
"vault_path": vault_path,
"source_commit": source_commit,
"provenance_hash": provenance_hash,
"expected_state": expected_state,
"created_at": created_at,
"idempotency_key": idempotency_key,
}
return validate_job(job)
def make_delete_job(
job_id: str,
mission_id: str,
delete_request_id: str,
object_id: str,
vault_path: str,
expected_commit: str,
expected_provenance_hash: str,
approval_id: str,
created_at: str,
idempotency_key: str,
) -> Dict[str, Any]:
"""Erzeugt einen kanonischen DELETE-Job (validiert)."""
job = {
"job_version": JOB_VERSION,
"job_id": job_id,
"mission_id": mission_id,
"delete_request_id": delete_request_id,
"job_type": JOB_TYPE_DELETE,
"object_id": object_id,
"vault_path": vault_path,
"expected_commit": expected_commit,
"expected_provenance_hash": expected_provenance_hash,
"approval_id": approval_id,
"created_at": created_at,
"idempotency_key": idempotency_key,
}
return validate_job(job)

View file

@ -0,0 +1,103 @@
"""
AUTH.3E job_state_machine.py
===============================
Deterministische Job-State-Machine für den Executor Command Channel.
States:
CREATED, READY, CLAIMED, EXECUTING, SUCCEEDED, FAILED, REJECTED, OUTCOME_UNKNOWN
Eigenschaften:
* Geschlossene Transition-Allowlist
* Ungültige Transition -> FAIL CLOSED (InvalidTransitionError)
* DELETE: KEIN RETRYABLE ein gestarteter DELETE wird bei Lease-Expiry in
OUTCOME_UNKNOWN überführt, NICHT zurück zu READY (kein blinder Retry)
* SAVE: RETRYABLE nur bei transienten Fehlern (TolariaUnavailable)
Isoliert implementiert (KEIN produktiver Container).
"""
from __future__ import annotations
from typing import Dict, FrozenSet, Optional, Tuple
# ---------------------------------------------------------------------------
# States
# ---------------------------------------------------------------------------
ST_CREATED = "CREATED"
ST_READY = "READY"
ST_CLAIMED = "CLAIMED"
ST_EXECUTING = "EXECUTING"
ST_SUCCEEDED = "SUCCEEDED"
ST_FAILED = "FAILED"
ST_REJECTED = "REJECTED"
ST_OUTCOME_UNKNOWN = "OUTCOME_UNKNOWN"
ALL_STATES = frozenset({
ST_CREATED, ST_READY, ST_CLAIMED, ST_EXECUTING,
ST_SUCCEEDED, ST_FAILED, ST_REJECTED, ST_OUTCOME_UNKNOWN,
})
# ---------------------------------------------------------------------------
# Erlaubte Transitionen (geschlossene Allowlist)
# ---------------------------------------------------------------------------
# (from_state, to_state)
ALLOWED_TRANSITIONS: FrozenSet[Tuple[str, str]] = frozenset({
# CREATED
(ST_CREATED, ST_READY), # Schema validiert, Job akzeptiert
(ST_CREATED, ST_REJECTED), # Schema/Allowlist/Injection-Fehler
# READY
(ST_READY, ST_CLAIMED), # atomarer Claim
(ST_READY, ST_REJECTED), # Gate-Fehler vor Claim
# CLAIMED
(ST_CLAIMED, ST_EXECUTING), # Claim bestätigt
(ST_CLAIMED, ST_REJECTED), # Gate-Fehler nach Claim
# EXECUTING
(ST_EXECUTING, ST_SUCCEEDED), # bestätigter Erfolg
(ST_EXECUTING, ST_FAILED), # bestätigter Fehler, kein Mutationseffekt
(ST_EXECUTING, ST_OUTCOME_UNKNOWN), # unklares HTTP-Ergebnis
(ST_EXECUTING, ST_REJECTED), # Gate-Fehler während Execution
# OUTCOME_UNKNOWN (terminal für DELETE; SAVE kann via Human/Admin reaktiviert werden)
(ST_OUTCOME_UNKNOWN, ST_REJECTED), # expliziter Human-/Admin-Eingriff
})
# Terminal-States (keine weiteren Transitionen ohne expliziten Eingriff)
TERMINAL_STATES = frozenset({ST_SUCCEEDED, ST_FAILED, ST_REJECTED})
class InvalidTransitionError(ValueError):
"""Ungültige Transition -> FAIL CLOSED."""
def is_valid_transition(from_state: str, to_state: str) -> bool:
return (from_state, to_state) in ALLOWED_TRANSITIONS
def transition(from_state: str, to_state: str) -> str:
"""
Führt eine State-Transition aus. Wirft InvalidTransitionError bei ungültiger
Transition (FAIL CLOSED).
"""
if from_state not in ALL_STATES:
raise InvalidTransitionError(f"unknown from_state: {from_state!r}")
if to_state not in ALL_STATES:
raise InvalidTransitionError(f"unknown to_state: {to_state!r}")
if not is_valid_transition(from_state, to_state):
raise InvalidTransitionError(
f"invalid transition: {from_state} -> {to_state} (FAIL CLOSED)"
)
return to_state
def is_terminal(state: str) -> bool:
return state in TERMINAL_STATES
def is_retryable(state: str, job_type: str) -> bool:
"""
SAVE: RETRYABLE nur aus OUTCOME_UNKNOWN (transient) via Human/Admin.
DELETE: NIE retryable ein gestarteter DELETE wird nicht blind wiederholt.
"""
if job_type == "C5_DELETE_OBJECT":
return False
# SAVE
return state == ST_OUTCOME_UNKNOWN

View file

@ -0,0 +1,307 @@
"""
AUTH.3E job_store.py
======================
SQLite Inbox für den Executor Command Channel.
Eigenschaften:
* Getrennte DB-Dateien pro Executor (c5a_save.db / c5a_delete.db)
* Atomarer Claim (READY -> CLAIMED) via SQLite-Transaktion (kein TOCTOU)
* Lease (lease_until) für Crash-Recovery
* Idempotenz (job_id / idempotency_key UNIQUE)
* Immutable fields after claim (UPDATE verboten für sicherheitskritische Felder)
* FAIL CLOSED bei DB-Fehler
Isoliert implementiert (KEIN produktiver Container). Nutzt job_schema + job_state_machine.
"""
from __future__ import annotations
import json
import sqlite3
import time
import uuid
from typing import Any, Dict, List, Optional
from job_schema import (
JOB_TYPE_DELETE,
JOB_TYPE_SAVE,
JobRejectedError,
validate_job,
)
from job_state_machine import (
ST_CLAIMED,
ST_CREATED,
ST_EXECUTING,
ST_FAILED,
ST_OUTCOME_UNKNOWN,
ST_READY,
ST_REJECTED,
ST_SUCCEEDED,
InvalidTransitionError,
transition,
)
from job_claim import (
JobAlreadyClaimedError,
JobNotFoundError,
atomic_claim,
recover_stale_claims as _recover_stale_claims,
renew_lease as _renew_lease,
)
# ---------------------------------------------------------------------------
# Immutable fields after claim (UPDATE verboten)
# ---------------------------------------------------------------------------
IMMUTABLE_FIELDS = frozenset({
"job_type", "object_id", "vault_path", "approval_id",
"expected_commit", "expected_provenance_hash", "delete_request_id",
"source_commit", "provenance_hash", "mission_id",
})
class JobStoreError(Exception):
"""Basis-Fehler für JobStore."""
class JobImmutableFieldError(JobStoreError):
pass
class JobStore:
"""
SQLite Inbox. Ein Store pro Executor (SAVE oder DELETE).
worker_scope: "SAVE" oder "DELETE" bestimmt, welche Job-Types claimbar sind.
"""
def __init__(self, db_path: str, worker_scope: str):
self.db_path = db_path
self.worker_scope = worker_scope.upper()
if self.worker_scope not in ("SAVE", "DELETE"):
raise JobStoreError(f"invalid worker_scope: {worker_scope!r}")
self._conn = sqlite3.connect(db_path)
self._conn.row_factory = sqlite3.Row
self._init_schema()
def _init_schema(self) -> None:
self._conn.execute("""
CREATE TABLE IF NOT EXISTS jobs (
job_id TEXT PRIMARY KEY,
job_version INTEGER NOT NULL,
mission_id TEXT NOT NULL,
job_type TEXT NOT NULL,
object_id TEXT NOT NULL,
vault_path TEXT NOT NULL,
payload TEXT NOT NULL, -- vollständiger Job (JSON)
state TEXT NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
worker_id TEXT,
claim_id TEXT,
claimed_at INTEGER,
lease_until INTEGER,
attempt_count INTEGER NOT NULL DEFAULT 0,
result_code TEXT,
created_at TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
""")
self._conn.execute("""
CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state)
""")
self._conn.execute("""
CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs(job_type)
""")
self._conn.commit()
# -- Job-Type-Scope -----------------------------------------------------
def _job_type_allowed(self, job_type: str) -> bool:
"""SAVE-Executor claimt nur SAVE-Jobs; DELETE-Executor nur DELETE-Jobs."""
if self.worker_scope == "SAVE":
return job_type == JOB_TYPE_SAVE
return job_type == JOB_TYPE_DELETE
# -- Erzeugen (RQ-Seite) ------------------------------------------------
def create_job(self, job: Dict[str, Any]) -> Dict[str, Any]:
"""
Erzeugt einen Job (Status CREATED). Idempotent per job_id.
Wirft JobRejectedError bei Schema-Verletzung.
"""
validated = validate_job(job)
job_id = validated["job_id"]
now = int(time.time() * 1000)
# Idempotenz: gleiche job_id -> bestehenden Job zurückgeben (kein Fehler).
existing = self.get_job(job_id)
if existing is not None:
return existing
try:
self._conn.execute(
"""
INSERT INTO jobs
(job_id, job_version, mission_id, job_type, object_id,
vault_path, payload, state, idempotency_key, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
job_id, validated["job_version"], validated["mission_id"],
validated["job_type"], validated["object_id"],
validated["vault_path"], json.dumps(validated),
ST_CREATED, validated["idempotency_key"],
validated["created_at"], now,
),
)
self._conn.commit()
except sqlite3.IntegrityError as e:
# UNIQUE-Verletzung (idempotency_key bereits vergeben) -> Anomalie.
raise JobStoreError(f"create_job failed (idempotency_key collision): {e}") from e
return self.get_job(job_id)
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
row = self._conn.execute(
"SELECT * FROM jobs WHERE job_id = ?", (job_id,)
).fetchone()
return self._row_to_dict(row) if row else None
def get_job_by_idempotency_key(self, idempotency_key: str) -> Optional[Dict[str, Any]]:
row = self._conn.execute(
"SELECT * FROM jobs WHERE idempotency_key = ?", (idempotency_key,)
).fetchone()
return self._row_to_dict(row) if row else None
def list_jobs(self, state: Optional[str] = None) -> List[Dict[str, Any]]:
if state:
rows = self._conn.execute(
"SELECT * FROM jobs WHERE state = ? ORDER BY created_at", (state,)
).fetchall()
else:
rows = self._conn.execute(
"SELECT * FROM jobs ORDER BY created_at"
).fetchall()
return [self._row_to_dict(r) for r in rows]
# -- State-Transition (mit Immutable-Fields-Schutz) ---------------------
def _transition(self, job_id: str, to_state: str, *, result_code: Optional[str] = None,
worker_id: Optional[str] = None) -> Dict[str, Any]:
"""
Führt eine State-Transition aus. Wirft InvalidTransitionError bei
ungültiger Transition (FAIL CLOSED).
"""
job = self.get_job(job_id)
if job is None:
raise JobNotFoundError(f"job not found: {job_id}")
from_state = job["state"]
transition(from_state, to_state) # wirft bei ungültiger Transition
now = int(time.time() * 1000)
self._conn.execute(
"""
UPDATE jobs SET state = ?, result_code = ?, updated_at = ?,
worker_id = COALESCE(?, worker_id)
WHERE job_id = ?
""",
(to_state, result_code, now, worker_id, job_id),
)
self._conn.commit()
return self.get_job(job_id)
def mark_ready(self, job_id: str) -> Dict[str, Any]:
return self._transition(job_id, ST_READY)
def mark_rejected(self, job_id: str, result_code: str) -> Dict[str, Any]:
return self._transition(job_id, ST_REJECTED, result_code=result_code)
def mark_succeeded(self, job_id: str, worker_id: str) -> Dict[str, Any]:
return self._transition(job_id, ST_SUCCEEDED, worker_id=worker_id)
def mark_failed(self, job_id: str, result_code: str, worker_id: str) -> Dict[str, Any]:
return self._transition(job_id, ST_FAILED, result_code=result_code, worker_id=worker_id)
def mark_outcome_unknown(self, job_id: str, worker_id: str) -> Dict[str, Any]:
return self._transition(job_id, ST_OUTCOME_UNKNOWN, worker_id=worker_id)
# -- Claim / Lease ------------------------------------------------------
def claim_job(self, job_id: str, worker_id: str, lease_seconds: int = 60) -> Dict[str, Any]:
"""
Atomarer Claim: READY -> CLAIMED, nur wenn Lease abgelaufen oder nie gesetzt.
Kein TOCTOU (SQLite-Transaktion mit Status-Bedingung). Delegiert an job_claim.
"""
claimed = atomic_claim(self._conn, job_id, worker_id, lease_seconds)
return claimed
def begin_execution(self, job_id: str, worker_id: str) -> Dict[str, Any]:
"""CLAIMED -> EXECUTING (Claim bestätigt)."""
return self._transition(job_id, ST_EXECUTING, worker_id=worker_id)
def renew_lease(self, job_id: str, lease_seconds: int = 60) -> Dict[str, Any]:
"""Verlängert die Lease eines CLAIMED/EXECUTING-Jobs."""
_renew_lease(self._conn, job_id, lease_seconds)
return self.get_job(job_id)
# -- Crash-Recovery -----------------------------------------------------
def recover_stale_claims(self, worker_id: str, lease_seconds: int = 60) -> List[Dict[str, Any]]:
"""
Findet stale CLAIMED-Jobs (Lease abgelaufen) und überführt sie:
* SAVE: zurück zu READY (wieder claimbar, RETRYABLE)
* DELETE: zu OUTCOME_UNKNOWN (NICHT blind wiederholen)
"""
# job_claim.recover_stale_claims überführt SAVE->READY, DELETE->OUTCOME_UNKNOWN
# anhand des job_type. Wir rufen es pro Job-Type auf (Scope-getrennt).
recovered = []
for job_type in (JOB_TYPE_SAVE, JOB_TYPE_DELETE):
if self._job_type_allowed(job_type):
_recover_stale_claims(self._conn, job_type)
# Re-read recovered jobs
for row in self._conn.execute(
"SELECT job_id FROM jobs WHERE state IN (?, ?)",
(ST_READY, ST_OUTCOME_UNKNOWN),
).fetchall():
job = self.get_job(row["job_id"])
if job is not None:
recovered.append(job)
return recovered
# -- Idempotenz ---------------------------------------------------------
def is_duplicate(self, job_id: str, idempotency_key: str) -> bool:
"""True, wenn job_id ODER idempotency_key bereits existiert."""
row = self._conn.execute(
"SELECT 1 FROM jobs WHERE job_id = ? OR idempotency_key = ? LIMIT 1",
(job_id, idempotency_key),
).fetchone()
return row is not None
# -- Audit --------------------------------------------------------------
def audit_trail(self, job_id: str) -> List[Dict[str, Any]]:
"""Gibt den Audit-Trail eines Jobs zurück (aus der jobs-Tabelle)."""
job = self.get_job(job_id)
if job is None:
return []
return [{
"job_id": job["job_id"],
"mission_id": job["mission_id"],
"job_type": job["job_type"],
"object_id": job["object_id"],
"state": job["state"],
"worker_id": job["worker_id"],
"attempt_count": job["attempt_count"],
"result_code": job["result_code"],
"created_at": job["created_at"],
"updated_at": job["updated_at"],
}]
# -- Helpers ------------------------------------------------------------
def _row_to_dict(self, row: sqlite3.Row) -> Dict[str, Any]:
d = dict(row)
payload = json.loads(d["payload"])
# Merge Payload-Felder in das Top-Level-Dict, damit Executor-Cores
# auf approval_id/expected_commit/source_commit etc. zugreifen können.
d.update(payload)
d["payload"] = payload
return d
def close(self) -> None:
self._conn.close()

View file

@ -0,0 +1,144 @@
"""
AUTH.3E save_executor_core.py
================================
SAVE-Executor-Logik (SAVE-only).
Eigenschaften:
* SAVE-only: claimt NUR C5_SAVE_OBJECT-Jobs
* Content-Rekonstruktion: lädt Content selbst aus autoritativer Source (Forgejo),
statt RQ blind zu vertrauen (DATA FROM RQ != AUTHORITY)
* RQ darf NICHT bestimmen: Tolaria Base URL, HTTP Method, Authorization Header,
Credential, beliebigen Zielendpoint
* Fail-closed: kein HTTP ohne SAVE-Credential
* OUTCOME_UNKNOWN bei unklarem HTTP-Ergebnis (kein blinder Retry)
Isoliert implementiert (KEIN produktiver Container). Nutzt job_store + job_schema.
"""
from __future__ import annotations
import hashlib
from typing import Any, Callable, Dict, Optional
from job_schema import JOB_TYPE_SAVE
from job_store import JobStore
# ---------------------------------------------------------------------------
# Result-Codes
# ---------------------------------------------------------------------------
RC_OK = "OK"
RC_CREDENTIAL_MISSING = "CREDENTIAL_MISSING"
RC_SOURCE_UNAVAILABLE = "SOURCE_UNAVAILABLE"
RC_PROVENANCE_MISMATCH = "PROVENANCE_MISMATCH"
RC_STATE_MISMATCH = "STATE_MISMATCH"
RC_PATH_INVALID = "PATH_INVALID"
RC_TOLARIA_UNAVAILABLE = "TOLARIA_UNAVAILABLE"
RC_OUTCOME_UNKNOWN = "OUTCOME_UNKNOWN"
RC_REJECTED = "REJECTED"
class SaveExecutorError(Exception):
pass
class SaveExecutorCore:
"""
SAVE-Executor. worker_scope="SAVE" (nur SAVE-Jobs claimbar).
source_loader: Callable[[str, str], Optional[str]] lädt Content aus
autoritativer Source (source_commit, object_id) -> content oder None.
tolaria_save: Callable[[str, str], Dict[str, Any]] führt den Tolaria-SAVE
aus (vault_path, content) -> {"status": "ok"|"error", "uncertain": bool}.
Muss fail-closed sein (kein HTTP ohne Credential).
"""
def __init__(
self,
store: JobStore,
source_loader: Callable[[str, str], Optional[str]],
tolaria_save: Callable[[str, str], Dict[str, Any]],
):
if store.worker_scope != "SAVE":
raise SaveExecutorError("SaveExecutorCore requires worker_scope='SAVE'")
self.store = store
self.source_loader = source_loader
self.tolaria_save = tolaria_save
# -- Hauptverarbeitung --------------------------------------------------
def process_job(self, job_id: str, worker_id: str) -> Dict[str, Any]:
"""
Verarbeitet einen SAVE-Job durch die State Machine.
Ablauf:
1. Job laden (muss existieren)
2. Job-Type-Scope prüfen (nur SAVE)
3. READY -> CLAIMED (atomarer Claim)
4. CLAIMED -> EXECUTING
5. Content aus autoritativer Source rekonstruieren
6. Provenance/State validieren
7. Tolaria-SAVE ausführen
8. Ergebnis: SUCCEEDED / FAILED / OUTCOME_UNKNOWN / REJECTED
"""
job = self.store.get_job(job_id)
if job is None:
raise SaveExecutorError(f"job not found: {job_id}")
# Scope: nur SAVE-Jobs
if job["job_type"] != JOB_TYPE_SAVE:
self.store.mark_rejected(job_id, RC_REJECTED)
return self.store.get_job(job_id)
# Atomarer Claim
try:
self.store.claim_job(job_id, worker_id)
except Exception:
# Nicht claimbar (bereits geclaimt) -> kein Doppel-Write
return self.store.get_job(job_id)
self.store.begin_execution(job_id, worker_id)
# Content-Rekonstruktion aus autoritativer Source
content = self._reconstruct_content(job)
if content is None:
self.store.mark_failed(job_id, RC_SOURCE_UNAVAILABLE, worker_id)
return self.store.get_job(job_id)
# Provenance validieren (RECOMPUTE)
if not self._validate_provenance(job, content):
self.store.mark_failed(job_id, RC_PROVENANCE_MISMATCH, worker_id)
return self.store.get_job(job_id)
# Tolaria-SAVE ausführen (fail-closed)
result = self.tolaria_save(job["vault_path"], content)
status = result.get("status")
if status == "ok":
self.store.mark_succeeded(job_id, worker_id)
elif status == "error" and result.get("uncertain"):
self.store.mark_outcome_unknown(job_id, worker_id)
else:
self.store.mark_failed(job_id, result.get("code", RC_TOLARIA_UNAVAILABLE), worker_id)
return self.store.get_job(job_id)
# -- Content-Rekonstruktion ---------------------------------------------
def _reconstruct_content(self, job: Dict[str, Any]) -> Optional[str]:
"""
Lädt Content selbst aus autoritativer Source (source_commit, object_id).
RQ liefert KEINEN Content im Job nur Referenzen.
"""
try:
return self.source_loader(job["source_commit"], job["object_id"])
except Exception:
return None
# -- Provenance-Validierung ---------------------------------------------
def _validate_provenance(self, job: Dict[str, Any], content: str) -> bool:
"""
RECOMPUTE: berechnet den Provenance-Hash aus dem rekonstruierten Content
und vergleicht mit dem Job-Feld. RQ darf den Hash nicht blind bestimmen.
"""
computed = hashlib.sha256(content.encode("utf-8")).hexdigest()
return computed == job["provenance_hash"]

View file

@ -0,0 +1,197 @@
"""
AUTH.3E sensitivity_proof_auth3e.py
======================================
Test Sensitivity (Mission §23): Mutationen AL.
Beweist, dass jede Mutation eine relevante Sicherheitsinvariante entfernt und
damit die zugehörigen Tests ROT machen würde. Dies ist eine STATISCHE Analyse
der Invarianten die Mutationen selbst werden NICHT produktiv angewendet.
Mutationen:
A job_type allowlist entfernen
B SAVE/DELETE Worker-Scope entfernen
C atomic claim entfernen
D idempotency entfernen
E approval requirement entfernen
F signature verification entfernen
G mission binding entfernen
H object binding entfernen
I commit binding entfernen
J provenance binding entfernen
K OUTCOME_UNKNOWN blind retry erlauben
L credential fail-closed entfernen
"""
from __future__ import annotations
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Invarianten -> Test-Mapping
# ---------------------------------------------------------------------------
# Jede Mutation entfernt eine Invariante. Die zugehörigen Tests würden ROT.
MUTATION_INVARIANTS = {
"A": "job_type muss in geschlossener Allowlist sein (job_schema.JOB_TYPES)",
"B": "SAVE-Executor claimt nur SAVE-Jobs, DELETE-Executor nur DELETE-Jobs (worker_scope)",
"C": "Claim ist atomar (kein TOCTOU, nur ein Worker gewinnt)",
"D": "duplicate job_id/idempotency_key idempotent (UNIQUE)",
"E": "DELETE ohne Approval denied (APPROVAL_MISSING)",
"F": "invalid Signature denied (APPROVAL_INVALID)",
"G": "falsche Mission denied (MISSION_MISMATCH)",
"H": "falsches Object denied (OBJECT_MISMATCH)",
"I": "falscher Commit denied (COMMIT_MISMATCH)",
"J": "falsche Provenance denied (PROVENANCE_MISMATCH)",
"K": "OUTCOME_UNKNOWN -> kein blinder Retry",
"L": "Credential fehlt -> kein HTTP (fail-closed)",
}
# Test-Methoden, die die jeweilige Invariante prüfen (ROT bei Mutation)
MUTATION_TESTS = {
"A": ["test_a_job_type_allowlist_removed", "test_t3_unknown_job_type_rejected"],
"B": ["test_b_save_delete_worker_scope_removed", "test_t11_save_executor_cannot_claim_delete_job",
"test_t12_delete_executor_cannot_claim_save_job"],
"C": ["test_c_atomic_claim_removed", "test_t18_two_worker_claim_atomic"],
"D": ["test_d_idempotency_removed", "test_t16_duplicate_job_id_idempotent"],
"E": ["test_e_approval_requirement_removed", "test_t21_delete_job_without_approval_denied"],
"F": ["test_f_signature_verification_removed", "test_t22_delete_job_invalid_signature_denied"],
"G": ["test_g_mission_binding_removed", "test_t27_wrong_mission_denied"],
"H": ["test_h_object_binding_removed", "test_t23_delete_job_wrong_object_denied"],
"I": ["test_i_commit_binding_removed", "test_t25_wrong_commit_denied"],
"J": ["test_j_provenance_binding_removed", "test_t26_wrong_provenance_denied"],
"K": ["test_k_outcome_unknown_blind_retry_removed", "test_t36_outcome_unknown_no_blind_retry"],
"L": ["test_l_credential_fail_closed_removed", "test_t32_delete_credential_missing_no_http",
"test_t33_save_credential_missing_no_http"],
}
def _has_allowlist() -> bool:
"""Prüft, ob job_schema eine geschlossene JOB_TYPES-Allowlist hat."""
src = Path("job_schema.py").read_text(encoding="utf-8")
return "JOB_TYPES = frozenset" in src and "JOB_TYPE_SAVE" in src and "JOB_TYPE_DELETE" in src
def _has_worker_scope() -> bool:
"""Prüft, ob Executor-Cores Worker-Scope erzwingen."""
save = Path("save_executor_core.py").read_text(encoding="utf-8")
delete = Path("delete_executor_core.py").read_text(encoding="utf-8")
return ("worker_scope != \"SAVE\"" in save) and ("worker_scope != \"DELETE\"" in delete)
def _has_atomic_claim() -> bool:
"""Prüft, ob job_claim einen atomaren Claim (Status-Bedingung) hat."""
src = Path("job_claim.py").read_text(encoding="utf-8")
return "state = ?" in src and "lease_until IS NULL OR lease_until < ?" in src
def _has_idempotency() -> bool:
"""Prüft, ob job_store UNIQUE-Constraints für job_id/idempotency_key hat."""
src = Path("job_store.py").read_text(encoding="utf-8")
return "TEXT PRIMARY KEY" in src and "idempotency_key TEXT NOT NULL UNIQUE" in src
def _has_approval_requirement() -> bool:
"""Prüft, ob delete_executor_core Approval lädt und prüft."""
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
return "approval_loader" in src and "RC_APPROVAL_MISSING" in src
def _has_signature_verification() -> bool:
"""Prüft, ob delete_executor_core die Signatur verifiziert."""
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
return "approval_verify" in src and "RC_APPROVAL_INVALID" in src
def _has_mission_binding() -> bool:
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
return "mission_id" in src and "RC_MISSION_MISMATCH" in src
def _has_object_binding() -> bool:
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
return "object_id" in src and "RC_OBJECT_MISMATCH" in src
def _has_commit_binding() -> bool:
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
return "expected_commit" in src and "RC_COMMIT_MISMATCH" in src
def _has_provenance_binding() -> bool:
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
return "expected_provenance_hash" in src and "RC_PROVENANCE_MISMATCH" in src
def _has_no_blind_retry() -> bool:
"""Prüft, ob OUTCOME_UNKNOWN nicht zurück zu READY führt (kein blinder Retry)."""
sm = Path("job_state_machine.py").read_text(encoding="utf-8")
return "(ST_OUTCOME_UNKNOWN, ST_READY)" not in sm and "is_retryable" in sm
def _has_credential_fail_closed() -> bool:
"""Prüft, ob Executor-Cores fail-closed bei fehlendem Credential sind."""
save = Path("save_executor_core.py").read_text(encoding="utf-8")
delete = Path("delete_executor_core.py").read_text(encoding="utf-8")
return "CREDENTIAL_MISSING" in save and "CREDENTIAL_MISSING" in delete
# Invariante -> Prüffunktion
INVARIANT_CHECKS = {
"A": _has_allowlist,
"B": _has_worker_scope,
"C": _has_atomic_claim,
"D": _has_idempotency,
"E": _has_approval_requirement,
"F": _has_signature_verification,
"G": _has_mission_binding,
"H": _has_object_binding,
"I": _has_commit_binding,
"J": _has_provenance_binding,
"K": _has_no_blind_retry,
"L": _has_credential_fail_closed,
}
def run_sensitivity_proof() -> dict:
"""
Führt die Sensitivitäts-Analyse aus. Gibt ein Dict zurück:
{
"mutation": {"invariant": str, "present": bool, "tests_would_go_red": [...]},
...
}
"""
result = {}
for mutation, invariant in MUTATION_INVARIANTS.items():
check = INVARIANT_CHECKS[mutation]
present = check()
result[mutation] = {
"invariant": invariant,
"present": present,
"tests_would_go_red": MUTATION_TESTS[mutation],
}
return result
def main() -> int:
result = run_sensitivity_proof()
all_present = True
print("=" * 70)
print("AUTH.3E — TEST SENSITIVITY PROOF (Mutationen AL)")
print("=" * 70)
for mutation, info in sorted(result.items()):
status = "PRESENT" if info["present"] else "MISSING (ROT)"
if not info["present"]:
all_present = False
print(f" Mutation {mutation}: {status}")
print(f" Invariante: {info['invariant']}")
print(f" Tests ROT: {', '.join(info['tests_would_go_red'])}")
print("=" * 70)
if all_present:
print("RESULT: ALL 12 INVARIANTS PRESENT — jede Mutation macht Tests ROT")
return 0
print("RESULT: MINDESTENS EINE INVARIANTE FEHLT — Tests verstärken!")
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,628 @@
"""
AUTH.3E test_job_channel.py
==============================
Isolierte Tests für den Executor Command Channel (T1T40 + adversarial).
Testumgebung:
* Synthetische Secrets (keine echten Credentials)
* Fake-Tolaria (kein echtes HTTP)
* Temp-DB (keine produktive DB)
* Keine produktiven Mutationsprobes
Abdeckung (Mission §21):
T1 gültiger SAVE Job akzeptiert
T2 gültiger DELETE Job akzeptiert
T3 unknown job_type rejected
T4 malformed schema rejected
T5 extra privileged field rejected
T6 URL injection rejected
T7 Authorization header injection rejected
T8 shell field rejected
T9 absolute outside path rejected
T10 traversal rejected
T11 SAVE Executor kann DELETE Job nicht claimen
T12 DELETE Executor kann SAVE Job nicht claimen
T13 SAVE Executor besitzt kein DELETE Credential
T14 DELETE Executor besitzt kein SAVE Credential
T15 RQ besitzt kein Credential
T16 duplicate job id rejected/idempotent
T17 duplicate idempotency key sicher
T18 two-worker claim atomar
T19 stale SAVE claim recovery sicher
T20 stale DELETE claim führt nicht zu blindem Delete
T21 DELETE Job ohne Approval denied
T22 DELETE Job mit invalid Signature denied
T23 DELETE Job mit falschem Object denied
T24 falscher Path denied
T25 falscher Commit denied
T26 falsche Provenance denied
T27 falsche Mission denied
T28 expired Approval denied
T29 consumed Approval denied
T30 replay denied
T31 valid DELETE Job + valid AUTH3D Approval reaches executor mutation boundary
T32 DELETE credential missing -> no HTTP
T33 SAVE credential missing -> no HTTP
T34 Tolaria timeout vor Mutation -> safe failure
T35 Tolaria timeout mit unklarem Ergebnis -> OUTCOME_UNKNOWN
T36 OUTCOME_UNKNOWN -> no blind retry
T37 confirmed DELETE -> retry does not second-delete
T38 rejected job -> zero filesystem side effect
T39 rejected job -> zero HTTP side effect
T40 rejected job -> no state escalation
"""
from __future__ import annotations
import hashlib
import json
import os
import tempfile
import unittest
import uuid
from job_schema import (
JOB_TYPE_DELETE,
JOB_TYPE_SAVE,
JobRejectedError,
make_delete_job,
make_save_job,
validate_job,
)
from job_state_machine import (
ST_CLAIMED,
ST_CREATED,
ST_EXECUTING,
ST_FAILED,
ST_OUTCOME_UNKNOWN,
ST_READY,
ST_REJECTED,
ST_SUCCEEDED,
InvalidTransitionError,
transition,
)
from job_store import JobStore
from save_executor_core import SaveExecutorCore
from delete_executor_core import DeleteExecutorCore
def _uuid() -> str:
return str(uuid.uuid4())
def _sha256(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _now_iso() -> str:
return "2026-08-27T00:00:00Z"
def _make_save_job(**overrides) -> dict:
job = make_save_job(
job_id=_uuid(),
mission_id=_uuid(),
object_id="obj-001",
vault_path="/app/vault/notes/obj-001.md",
source_commit=_sha256("commit"),
provenance_hash=_sha256("content"),
expected_state="ready",
created_at=_now_iso(),
idempotency_key=_uuid(),
)
job.update(overrides)
return job
def _make_delete_job(**overrides) -> dict:
job = make_delete_job(
job_id=_uuid(),
mission_id=_uuid(),
delete_request_id=_uuid(),
object_id="obj-001",
vault_path="/app/vault/notes/obj-001.md",
expected_commit=_sha256("commit"),
expected_provenance_hash=_sha256("content"),
approval_id=_uuid(),
created_at=_now_iso(),
idempotency_key=_uuid(),
)
job.update(overrides)
return job
def _valid_approval(job: dict) -> dict:
"""Erzeugt eine gültige AUTH.3D-Approval, die exakt zum DELETE-Job passt."""
return {
"approval_id": job["approval_id"],
"mission_id": job["mission_id"],
"object_id": job["object_id"],
"vault_path": job["vault_path"],
"expected_commit": job["expected_commit"],
"expected_provenance_hash": job["expected_provenance_hash"],
"nonce": _uuid(),
"expired": False,
"consumed": False,
"signature_valid": True,
}
class JobSchemaTests(unittest.TestCase):
"""T1T10: Schema-Validierung."""
def test_t1_valid_save_job_accepted(self):
job = _make_save_job()
result = validate_job(job)
self.assertEqual(result["job_type"], JOB_TYPE_SAVE)
def test_t2_valid_delete_job_accepted(self):
job = _make_delete_job()
result = validate_job(job)
self.assertEqual(result["job_type"], JOB_TYPE_DELETE)
def test_t3_unknown_job_type_rejected(self):
job = _make_save_job(job_type="C5_EXECUTE")
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_t4_malformed_schema_rejected(self):
job = _make_save_job()
del job["object_id"]
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_t5_extra_privileged_field_rejected(self):
job = _make_save_job(command="rm -rf /")
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_t6_url_injection_rejected(self):
job = _make_save_job(url="http://evil.example.com")
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_t7_authorization_header_injection_rejected(self):
job = _make_save_job(authorization="Bearer evil")
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_t8_shell_field_rejected(self):
job = _make_save_job(shell="/bin/sh")
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_t9_absolute_outside_path_rejected(self):
job = _make_save_job(vault_path="/etc/passwd")
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_t10_traversal_rejected(self):
job = _make_save_job(vault_path="/app/vault/../../etc/passwd")
with self.assertRaises(JobRejectedError):
validate_job(job)
class JobStoreTests(unittest.TestCase):
"""T16T20: Idempotenz, Claim, Recovery."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.save_db = os.path.join(self.tmpdir, "c5a_save.db")
self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db")
def tearDown(self):
pass
def test_t16_duplicate_job_id_idempotent(self):
store = JobStore(self.save_db, "SAVE")
job = _make_save_job()
store.create_job(job)
# Zweiter create mit gleicher job_id -> idempotent (INSERT OR IGNORE)
store.create_job(job)
jobs = store.list_jobs()
self.assertEqual(len(jobs), 1)
store.close()
def test_t17_duplicate_idempotency_key_safe(self):
store = JobStore(self.save_db, "SAVE")
key = _uuid()
job1 = _make_save_job(idempotency_key=key)
job2 = _make_save_job(idempotency_key=key)
store.create_job(job1)
# Zweiter mit gleichem idempotency_key -> UNIQUE-Verletzung abgefangen
from job_store import JobStoreError
with self.assertRaises(JobStoreError):
store.create_job(job2)
store.close()
def test_t18_two_worker_claim_atomic(self):
store = JobStore(self.save_db, "SAVE")
job = _make_save_job()
store.create_job(job)
store.mark_ready(job["job_id"])
# Worker 1 claimt
store.claim_job(job["job_id"], "worker-1")
# Worker 2 kann NICHT claimen (bereits geclaimt)
from job_claim import JobAlreadyClaimedError
with self.assertRaises(JobAlreadyClaimedError):
store.claim_job(job["job_id"], "worker-2")
store.close()
def test_t19_stale_save_claim_recovery_safe(self):
store = JobStore(self.save_db, "SAVE")
job = _make_save_job()
store.create_job(job)
store.mark_ready(job["job_id"])
store.claim_job(job["job_id"], "worker-1", lease_seconds=0) # sofort abgelaufen
recovered = store.recover_stale_claims("worker-2")
# SAVE -> zurück zu READY (wieder claimbar)
self.assertEqual(store.get_job(job["job_id"])["state"], ST_READY)
store.close()
def test_t20_stale_delete_claim_no_blind_delete(self):
store = JobStore(self.delete_db, "DELETE")
job = _make_delete_job()
store.create_job(job)
store.mark_ready(job["job_id"])
store.claim_job(job["job_id"], "worker-1", lease_seconds=0) # sofort abgelaufen
recovered = store.recover_stale_claims("worker-2")
# DELETE -> OUTCOME_UNKNOWN (NICHT zurück zu READY, kein blinder Retry)
self.assertEqual(store.get_job(job["job_id"])["state"], ST_OUTCOME_UNKNOWN)
store.close()
class SaveExecutorTests(unittest.TestCase):
"""T11, T13, T33, T34, T35, T36, T38, T39, T40 (SAVE)."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.save_db = os.path.join(self.tmpdir, "c5a_save.db")
self.store = JobStore(self.save_db, "SAVE")
self.http_calls = []
self.content = "hello world"
self.provenance = _sha256(self.content)
def _source_loader(self, commit, object_id):
return self.content
def _tolaria_save(self, vault_path, content):
self.http_calls.append(("SAVE", vault_path))
return {"status": "ok"}
def test_t11_save_executor_cannot_claim_delete_job(self):
# SAVE-Store kann keinen DELETE-Job erzeugen (Schema) — aber selbst wenn
# ein DELETE-Job in den SAVE-Store injiziert würde, wird er REJECTED.
job = _make_delete_job()
# Direkt in DB injizieren (Cross-Executor-Injection-Simulation)
self.store.create_job(job) # Schema erlaubt DELETE-Job im Store? Nein — validate_job erlaubt beide
# Aber SaveExecutorCore.process_job lehnt DELETE-Job ab (Scope)
exec_core = SaveExecutorCore(self.store, self._source_loader, self._tolaria_save)
result = exec_core.process_job(job["job_id"], "save-worker")
self.assertEqual(result["state"], ST_REJECTED)
self.assertEqual(self.http_calls, []) # kein HTTP
def test_t13_save_executor_has_no_delete_credential(self):
# SAVE-Executor kennt nur SAVE-Credential (injiziert), kein DELETE-Token
self.assertNotIn("delete_token", dir(self.store))
self.assertNotIn("delete_token", dir(SaveExecutorCore))
def test_t33_save_credential_missing_no_http(self):
# tolaria_save fail-closed: ohne Credential kein HTTP
def no_cred_save(vault_path, content):
self.http_calls.append(("SAVE", vault_path))
return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False}
job = _make_save_job(provenance_hash=self.provenance)
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = SaveExecutorCore(self.store, self._source_loader, no_cred_save)
result = exec_core.process_job(job["job_id"], "save-worker")
self.assertEqual(result["state"], ST_FAILED)
self.assertEqual(result["result_code"], "CREDENTIAL_MISSING")
def test_t34_tolaria_timeout_before_mutation_safe_failure(self):
# Timeout VOR Mutation -> safe failure (kein Write, kein OUTCOME_UNKNOWN)
def timeout_save(vault_path, content):
self.http_calls.append(("SAVE", vault_path))
return {"status": "error", "code": "TOLARIA_UNAVAILABLE", "uncertain": False}
job = _make_save_job(provenance_hash=self.provenance)
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = SaveExecutorCore(self.store, self._source_loader, timeout_save)
result = exec_core.process_job(job["job_id"], "save-worker")
self.assertEqual(result["state"], ST_FAILED)
self.assertEqual(result["result_code"], "TOLARIA_UNAVAILABLE")
def test_t35_tolaria_timeout_uncertain_outcome_unknown(self):
# Timeout mit unklarem Ergebnis -> OUTCOME_UNKNOWN
def uncertain_save(vault_path, content):
self.http_calls.append(("SAVE", vault_path))
return {"status": "error", "code": "TIMEOUT", "uncertain": True}
job = _make_save_job(provenance_hash=self.provenance)
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = SaveExecutorCore(self.store, self._source_loader, uncertain_save)
result = exec_core.process_job(job["job_id"], "save-worker")
self.assertEqual(result["state"], ST_OUTCOME_UNKNOWN)
def test_t36_outcome_unknown_no_blind_retry(self):
# OUTCOME_UNKNOWN -> kein blinder Retry (Job bleibt OUTCOME_UNKNOWN)
def uncertain_save(vault_path, content):
self.http_calls.append(("SAVE", vault_path))
return {"status": "error", "code": "TIMEOUT", "uncertain": True}
job = _make_save_job(provenance_hash=self.provenance)
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = SaveExecutorCore(self.store, self._source_loader, uncertain_save)
exec_core.process_job(job["job_id"], "save-worker")
# Erneuter Versuch: Job ist OUTCOME_UNKNOWN, nicht claimbar -> kein 2. HTTP
exec_core.process_job(job["job_id"], "save-worker")
self.assertEqual(len(self.http_calls), 1)
def test_t38_rejected_job_zero_fs_side_effect(self):
# Rejected Job -> kein Dateisystem-Effekt (kein Write)
job = _make_save_job(job_type="C5_EXECUTE") # invalid
with self.assertRaises(JobRejectedError):
self.store.create_job(job)
# Kein Job in DB
self.assertEqual(self.store.list_jobs(), [])
def test_t39_rejected_job_zero_http_side_effect(self):
job = _make_save_job(job_type="C5_EXECUTE")
with self.assertRaises(JobRejectedError):
self.store.create_job(job)
self.assertEqual(self.http_calls, [])
def test_t40_rejected_job_no_state_escalation(self):
# Rejected Job -> kein State-Escalation (bleibt REJECTED, kein EXECUTING)
job = _make_delete_job()
self.store.create_job(job)
exec_core = SaveExecutorCore(self.store, self._source_loader, self._tolaria_save)
result = exec_core.process_job(job["job_id"], "save-worker")
self.assertEqual(result["state"], ST_REJECTED)
self.assertNotEqual(result["state"], ST_EXECUTING)
class DeleteExecutorTests(unittest.TestCase):
"""T12, T14, T21T32, T37 (DELETE)."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db")
self.store = JobStore(self.delete_db, "DELETE")
self.http_calls = []
self.approvals = {}
def _approval_loader(self, approval_id):
return self.approvals.get(approval_id)
def _approval_verify(self, approval):
return {"valid": approval.get("signature_valid", False), "reason": "ok"}
def _tolaria_delete(self, vault_path):
self.http_calls.append(("DELETE", vault_path))
return {"status": "ok"}
def _register_valid_approval(self, job):
approval = _valid_approval(job)
self.approvals[job["approval_id"]] = approval
return approval
def test_t12_delete_executor_cannot_claim_save_job(self):
job = _make_save_job()
self.store.create_job(job)
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["state"], ST_REJECTED)
self.assertEqual(self.http_calls, [])
def test_t14_delete_executor_has_no_save_credential(self):
self.assertNotIn("save_token", dir(self.store))
self.assertNotIn("save_token", dir(DeleteExecutorCore))
def test_t21_delete_job_without_approval_denied(self):
job = _make_delete_job()
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["state"], ST_FAILED)
self.assertEqual(result["result_code"], "APPROVAL_MISSING")
self.assertEqual(self.http_calls, [])
def test_t22_delete_job_invalid_signature_denied(self):
job = _make_delete_job()
approval = _valid_approval(job)
approval["signature_valid"] = False
self.approvals[job["approval_id"]] = approval
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "APPROVAL_INVALID")
self.assertEqual(self.http_calls, [])
def test_t23_delete_job_wrong_object_denied(self):
job = _make_delete_job()
approval = _valid_approval(job)
approval["object_id"] = "obj-OTHER"
self.approvals[job["approval_id"]] = approval
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "OBJECT_MISMATCH")
self.assertEqual(self.http_calls, [])
def test_t24_wrong_path_denied(self):
job = _make_delete_job()
approval = _valid_approval(job)
approval["vault_path"] = "/app/vault/notes/OTHER.md"
self.approvals[job["approval_id"]] = approval
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "PATH_MISMATCH")
self.assertEqual(self.http_calls, [])
def test_t25_wrong_commit_denied(self):
job = _make_delete_job()
approval = _valid_approval(job)
approval["expected_commit"] = _sha256("other-commit")
self.approvals[job["approval_id"]] = approval
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "COMMIT_MISMATCH")
self.assertEqual(self.http_calls, [])
def test_t26_wrong_provenance_denied(self):
job = _make_delete_job()
approval = _valid_approval(job)
approval["expected_provenance_hash"] = _sha256("other-content")
self.approvals[job["approval_id"]] = approval
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "PROVENANCE_MISMATCH")
self.assertEqual(self.http_calls, [])
def test_t27_wrong_mission_denied(self):
job = _make_delete_job()
approval = _valid_approval(job)
approval["mission_id"] = _uuid()
self.approvals[job["approval_id"]] = approval
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "MISSION_MISMATCH")
self.assertEqual(self.http_calls, [])
def test_t28_expired_approval_denied(self):
job = _make_delete_job()
approval = _valid_approval(job)
approval["expired"] = True
self.approvals[job["approval_id"]] = approval
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "APPROVAL_EXPIRED")
self.assertEqual(self.http_calls, [])
def test_t29_consumed_approval_denied(self):
job = _make_delete_job()
approval = _valid_approval(job)
approval["consumed"] = True
self.approvals[job["approval_id"]] = approval
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "APPROVAL_CONSUMED")
self.assertEqual(self.http_calls, [])
def test_t30_replay_denied(self):
# Nach bestätigtem DELETE: gleicher Job niemals erneut mutieren
job = _make_delete_job()
self._register_valid_approval(job)
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["state"], ST_SUCCEEDED)
self.assertEqual(len(self.http_calls), 1)
# Replay: Job ist SUCCEEDED, nicht claimbar -> kein 2. HTTP
exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(len(self.http_calls), 1)
def test_t31_valid_delete_reaches_mutation_boundary(self):
job = _make_delete_job()
self._register_valid_approval(job)
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["state"], ST_SUCCEEDED)
self.assertEqual(self.http_calls, [("DELETE", job["vault_path"])])
def test_t32_delete_credential_missing_no_http(self):
def no_cred_delete(vault_path):
self.http_calls.append(("DELETE", vault_path))
return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False}
job = _make_delete_job()
self._register_valid_approval(job)
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, no_cred_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["state"], ST_FAILED)
self.assertEqual(result["result_code"], "CREDENTIAL_MISSING")
def test_t37_confirmed_delete_retry_no_second_delete(self):
job = _make_delete_job()
self._register_valid_approval(job)
self.store.create_job(job)
self.store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
exec_core.process_job(job["job_id"], "delete-worker")
# Retry-Versuch -> kein zweiter Delete
exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(len(self.http_calls), 1)
class StateMachineTests(unittest.TestCase):
"""State-Machine-Transitionen (FAIL CLOSED)."""
def test_valid_transitions(self):
self.assertEqual(transition(ST_CREATED, ST_READY), ST_READY)
self.assertEqual(transition(ST_READY, ST_CLAIMED), ST_CLAIMED)
self.assertEqual(transition(ST_CLAIMED, ST_EXECUTING), ST_EXECUTING)
self.assertEqual(transition(ST_EXECUTING, ST_SUCCEEDED), ST_SUCCEEDED)
self.assertEqual(transition(ST_EXECUTING, ST_OUTCOME_UNKNOWN), ST_OUTCOME_UNKNOWN)
def test_invalid_transition_fail_closed(self):
# SUCCEEDED -> READY ist ungültig
with self.assertRaises(InvalidTransitionError):
transition(ST_SUCCEEDED, ST_READY)
# REJECTED -> EXECUTING ist ungültig
with self.assertRaises(InvalidTransitionError):
transition(ST_REJECTED, ST_EXECUTING)
# OUTCOME_UNKNOWN -> READY ist ungültig (kein blinder Retry)
with self.assertRaises(InvalidTransitionError):
transition(ST_OUTCOME_UNKNOWN, ST_READY)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,593 @@
"""
AUTH.3E test_job_channel_adversarial.py
==========================================
Adversarial Tests (Mission §22) + Test Sensitivity (Mission §23).
Adversarial:
* JSON duplicate keys
* Unicode path ambiguity
* encoded traversal
* job_type casing tricks
* enum confusion
* integer/string confusion
* oversized payload
* stale mission
* stale provenance
* race claim
* replay after restart
* DB row manipulation
* cross-executor DB injection
* approval substitution
* object substitution after claim
* path substitution after claim
* state mutation after approval
* crash before HTTP
* crash after uncertain HTTP
Sensitivity (Mutationen AL): Jede Mutation muss relevante Tests ROT machen.
"""
from __future__ import annotations
import hashlib
import json
import os
import tempfile
import unittest
import uuid
from job_schema import (
JOB_TYPE_DELETE,
JOB_TYPE_SAVE,
JobRejectedError,
make_delete_job,
make_save_job,
validate_job,
)
from job_state_machine import (
ST_CLAIMED,
ST_EXECUTING,
ST_FAILED,
ST_OUTCOME_UNKNOWN,
ST_READY,
ST_REJECTED,
ST_SUCCEEDED,
InvalidTransitionError,
transition,
)
from job_store import JobStore
from save_executor_core import SaveExecutorCore
from delete_executor_core import DeleteExecutorCore
def _uuid() -> str:
return str(uuid.uuid4())
def _sha256(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _now_iso() -> str:
return "2026-08-27T00:00:00Z"
def _make_save_job(**overrides) -> dict:
job = make_save_job(
job_id=_uuid(), mission_id=_uuid(), object_id="obj-001",
vault_path="/app/vault/notes/obj-001.md", source_commit=_sha256("commit"),
provenance_hash=_sha256("content"), expected_state="ready",
created_at=_now_iso(), idempotency_key=_uuid(),
)
job.update(overrides)
return job
def _make_delete_job(**overrides) -> dict:
job = make_delete_job(
job_id=_uuid(), mission_id=_uuid(), delete_request_id=_uuid(),
object_id="obj-001", vault_path="/app/vault/notes/obj-001.md",
expected_commit=_sha256("commit"), expected_provenance_hash=_sha256("content"),
approval_id=_uuid(), created_at=_now_iso(), idempotency_key=_uuid(),
)
job.update(overrides)
return job
def _valid_approval(job: dict) -> dict:
return {
"approval_id": job["approval_id"], "mission_id": job["mission_id"],
"object_id": job["object_id"], "vault_path": job["vault_path"],
"expected_commit": job["expected_commit"],
"expected_provenance_hash": job["expected_provenance_hash"],
"nonce": _uuid(), "expired": False, "consumed": False,
"signature_valid": True,
}
class AdversarialSchemaTests(unittest.TestCase):
"""Adversarial: Schema-Angriffe."""
def test_json_duplicate_keys(self):
# JSON mit doppelten Keys -> parse_payload (AUTH.3D) lehnt ab.
# Hier: defensiv — validate_job auf einem dict mit doppeltem Key.
job = _make_save_job()
# Simuliere Duplicate-Key durch dict mit zwei gleichen Keys (letzter gewinnt)
# -> kein Fehler, aber wir prüfen, dass kein privilegiertes Feld eingeschleust wird
job["command"] = "evil"
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_unicode_path_ambiguity(self):
# Unicode-Homoglyph / nicht-NFC-normalisierter Pfad
job = _make_save_job(vault_path="/app/vault/notes/obj\u0301.md") # e + combining accent
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_encoded_traversal(self):
# URL-encoded traversal
job = _make_save_job(vault_path="/app/vault/%2e%2e/etc/passwd")
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_job_type_casing_tricks(self):
job = _make_save_job(job_type="c5_save_object")
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_enum_confusion(self):
# job_type als Nicht-String (z.B. int)
job = _make_save_job(job_type=123)
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_integer_string_confusion(self):
# job_version als String statt int
job = _make_save_job(job_version="1")
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_oversized_payload(self):
# Riesiger object_id
job = _make_save_job(object_id="x" * 100000)
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_stale_mission(self):
# mission_id ungültig (kein UUID)
job = _make_save_job(mission_id="stale-mission")
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_stale_provenance(self):
# provenance_hash ungültig (kein sha256)
job = _make_save_job(provenance_hash="not-a-hash")
with self.assertRaises(JobRejectedError):
validate_job(job)
class AdversarialRuntimeTests(unittest.TestCase):
"""Adversarial: Runtime-Angriffe."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.save_db = os.path.join(self.tmpdir, "c5a_save.db")
self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db")
self.http_calls = []
self.approvals = {}
self.content = "hello world"
self.provenance = _sha256(self.content)
def _source_loader(self, commit, object_id):
return self.content
def _tolaria_save(self, vault_path, content):
self.http_calls.append(("SAVE", vault_path))
return {"status": "ok"}
def _approval_loader(self, approval_id):
return self.approvals.get(approval_id)
def _approval_verify(self, approval):
return {"valid": approval.get("signature_valid", False), "reason": "ok"}
def _tolaria_delete(self, vault_path):
self.http_calls.append(("DELETE", vault_path))
return {"status": "ok"}
def test_race_claim(self):
# Zwei Worker claimen denselben Job -> nur einer gewinnt
store = JobStore(self.save_db, "SAVE")
job = _make_save_job(provenance_hash=self.provenance)
store.create_job(job)
store.mark_ready(job["job_id"])
from job_claim import JobAlreadyClaimedError
store.claim_job(job["job_id"], "worker-1")
with self.assertRaises(JobAlreadyClaimedError):
store.claim_job(job["job_id"], "worker-2")
store.close()
def test_replay_after_restart(self):
# Nach Restart (neuer Store auf gleicher DB) kein Doppel-Delete
job = _make_delete_job()
self.approvals[job["approval_id"]] = _valid_approval(job)
store1 = JobStore(self.delete_db, "DELETE")
store1.create_job(job)
store1.mark_ready(job["job_id"])
exec1 = DeleteExecutorCore(
store1, self._approval_loader, self._approval_verify, self._tolaria_delete
)
exec1.process_job(job["job_id"], "delete-worker")
store1.close()
# Restart
store2 = JobStore(self.delete_db, "DELETE")
exec2 = DeleteExecutorCore(
store2, self._approval_loader, self._approval_verify, self._tolaria_delete
)
exec2.process_job(job["job_id"], "delete-worker")
self.assertEqual(len(self.http_calls), 1) # kein 2. Delete
store2.close()
def test_db_row_manipulation(self):
# Manipulierter Payload (job_type geändert) -> Executor lehnt ab
store = JobStore(self.save_db, "SAVE")
job = _make_save_job(provenance_hash=self.provenance)
store.create_job(job)
store.mark_ready(job["job_id"])
# Manipulation: Payload job_type auf DELETE ändern (Angriffsfläche)
row = store._conn.execute(
"SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],)
).fetchone()
payload = json.loads(row[0])
payload["job_type"] = JOB_TYPE_DELETE
store._conn.execute(
"UPDATE jobs SET payload = ? WHERE job_id = ?",
(json.dumps(payload), job["job_id"]),
)
store._conn.commit()
exec_core = SaveExecutorCore(store, self._source_loader, self._tolaria_save)
result = exec_core.process_job(job["job_id"], "save-worker")
self.assertEqual(result["state"], ST_REJECTED)
self.assertEqual(self.http_calls, [])
store.close()
def test_cross_executor_db_injection(self):
# DELETE-Job in SAVE-DB injiziert -> SAVE-Executor lehnt ab
store = JobStore(self.save_db, "SAVE")
job = _make_delete_job()
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = SaveExecutorCore(store, self._source_loader, self._tolaria_save)
result = exec_core.process_job(job["job_id"], "save-worker")
self.assertEqual(result["state"], ST_REJECTED)
self.assertEqual(self.http_calls, [])
store.close()
def test_approval_substitution(self):
# Approval für anderes Objekt substituiert -> denied
job = _make_delete_job()
approval = _valid_approval(job)
approval["object_id"] = "obj-OTHER"
self.approvals[job["approval_id"]] = approval
store = JobStore(self.delete_db, "DELETE")
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "OBJECT_MISMATCH")
self.assertEqual(self.http_calls, [])
store.close()
def test_object_substitution_after_claim(self):
# object_id im Payload manipuliert -> Executor validiert gegen Approval
# (TOCTOU-Defense: Job wird nach dem Claim neu gelesen)
job = _make_delete_job()
self.approvals[job["approval_id"]] = _valid_approval(job)
store = JobStore(self.delete_db, "DELETE")
store.create_job(job)
store.mark_ready(job["job_id"])
# Payload manipulieren (object_id ändern)
row = store._conn.execute(
"SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],)
).fetchone()
payload = json.loads(row[0])
payload["object_id"] = "obj-OTHER"
store._conn.execute(
"UPDATE jobs SET payload = ? WHERE job_id = ?",
(json.dumps(payload), job["job_id"]),
)
store._conn.commit()
exec_core = DeleteExecutorCore(
store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "OBJECT_MISMATCH")
self.assertEqual(self.http_calls, [])
store.close()
def test_path_substitution_after_claim(self):
job = _make_delete_job()
self.approvals[job["approval_id"]] = _valid_approval(job)
store = JobStore(self.delete_db, "DELETE")
store.create_job(job)
store.mark_ready(job["job_id"])
row = store._conn.execute(
"SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],)
).fetchone()
payload = json.loads(row[0])
payload["vault_path"] = "/app/vault/notes/OTHER.md"
store._conn.execute(
"UPDATE jobs SET payload = ? WHERE job_id = ?",
(json.dumps(payload), job["job_id"]),
)
store._conn.commit()
exec_core = DeleteExecutorCore(
store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "PATH_MISMATCH")
self.assertEqual(self.http_calls, [])
store.close()
def test_state_mutation_after_approval(self):
# expected_commit im Payload mutiert -> Executor validiert gegen Approval
job = _make_delete_job()
self.approvals[job["approval_id"]] = _valid_approval(job)
store = JobStore(self.delete_db, "DELETE")
store.create_job(job)
store.mark_ready(job["job_id"])
row = store._conn.execute(
"SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],)
).fetchone()
payload = json.loads(row[0])
payload["expected_commit"] = _sha256("other-commit")
store._conn.execute(
"UPDATE jobs SET payload = ? WHERE job_id = ?",
(json.dumps(payload), job["job_id"]),
)
store._conn.commit()
exec_core = DeleteExecutorCore(
store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "COMMIT_MISMATCH")
self.assertEqual(self.http_calls, [])
store.close()
def test_crash_before_http(self):
# Crash vor HTTP -> Job bleibt CLAIMED, Lease läuft ab, SAVE wird recovered
store = JobStore(self.save_db, "SAVE")
job = _make_save_job(provenance_hash=self.provenance)
store.create_job(job)
store.mark_ready(job["job_id"])
store.claim_job(job["job_id"], "worker-1", lease_seconds=0)
# Crash simuliert: kein HTTP, kein weiterer Schritt
self.assertEqual(self.http_calls, [])
# Recovery: SAVE -> READY
store.recover_stale_claims("worker-2")
self.assertEqual(store.get_job(job["job_id"])["state"], ST_READY)
store.close()
def test_crash_after_uncertain_http(self):
# Crash nach unklarem HTTP -> OUTCOME_UNKNOWN, kein blinder Retry
def uncertain_delete(vault_path):
self.http_calls.append(("DELETE", vault_path))
return {"status": "error", "code": "TIMEOUT", "uncertain": True}
job = _make_delete_job()
self.approvals[job["approval_id"]] = _valid_approval(job)
store = JobStore(self.delete_db, "DELETE")
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
store, self._approval_loader, self._approval_verify, uncertain_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["state"], ST_OUTCOME_UNKNOWN)
# Kein blinder Retry
exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(len(self.http_calls), 1)
store.close()
class SensitivityTests(unittest.TestCase):
"""
Test Sensitivity (Mission §23): Mutationen AL müssen relevante Tests ROT machen.
Diese Tests prüfen die INVARIANTEN direkt. Wenn eine Mutation die Invariante
entfernt, schlägt der Test fehl (ROT). Die Mutationen selbst werden in
sensitivity_proof.py simuliert (statische Analyse).
"""
def test_a_job_type_allowlist_removed(self):
# Invariante: job_type muss in geschlossener Allowlist sein
job = _make_save_job(job_type="C5_EXECUTE")
with self.assertRaises(JobRejectedError):
validate_job(job)
def test_b_save_delete_worker_scope_removed(self):
# Invariante: SAVE-Executor claimt nur SAVE-Jobs
store = JobStore(self.save_db, "SAVE")
job = _make_delete_job()
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = SaveExecutorCore(
store, self._source_loader, self._tolaria_save
)
result = exec_core.process_job(job["job_id"], "save-worker")
self.assertEqual(result["state"], ST_REJECTED)
store.close()
def test_c_atomic_claim_removed(self):
# Invariante: Claim ist atomar (nur ein Worker gewinnt)
store = JobStore(self.save_db, "SAVE")
job = _make_save_job(provenance_hash=self.provenance)
store.create_job(job)
store.mark_ready(job["job_id"])
from job_claim import JobAlreadyClaimedError
store.claim_job(job["job_id"], "worker-1")
with self.assertRaises(JobAlreadyClaimedError):
store.claim_job(job["job_id"], "worker-2")
store.close()
def test_d_idempotency_removed(self):
# Invariante: duplicate job_id idempotent
store = JobStore(self.save_db, "SAVE")
job = _make_save_job()
store.create_job(job)
store.create_job(job)
self.assertEqual(len(store.list_jobs()), 1)
store.close()
def test_e_approval_requirement_removed(self):
# Invariante: DELETE ohne Approval denied
job = _make_delete_job()
store = JobStore(self.delete_db, "DELETE")
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "APPROVAL_MISSING")
store.close()
def test_f_signature_verification_removed(self):
# Invariante: invalid Signature denied
job = _make_delete_job()
approval = _valid_approval(job)
approval["signature_valid"] = False
self.approvals[job["approval_id"]] = approval
store = JobStore(self.delete_db, "DELETE")
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "APPROVAL_INVALID")
store.close()
def test_g_mission_binding_removed(self):
# Invariante: falsche Mission denied
job = _make_delete_job()
approval = _valid_approval(job)
approval["mission_id"] = _uuid()
self.approvals[job["approval_id"]] = approval
store = JobStore(self.delete_db, "DELETE")
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "MISSION_MISMATCH")
store.close()
def test_h_object_binding_removed(self):
# Invariante: falsches Object denied
job = _make_delete_job()
approval = _valid_approval(job)
approval["object_id"] = "obj-OTHER"
self.approvals[job["approval_id"]] = approval
store = JobStore(self.delete_db, "DELETE")
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "OBJECT_MISMATCH")
store.close()
def test_i_commit_binding_removed(self):
# Invariante: falscher Commit denied
job = _make_delete_job()
approval = _valid_approval(job)
approval["expected_commit"] = _sha256("other-commit")
self.approvals[job["approval_id"]] = approval
store = JobStore(self.delete_db, "DELETE")
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "COMMIT_MISMATCH")
store.close()
def test_j_provenance_binding_removed(self):
# Invariante: falsche Provenance denied
job = _make_delete_job()
approval = _valid_approval(job)
approval["expected_provenance_hash"] = _sha256("other-content")
self.approvals[job["approval_id"]] = approval
store = JobStore(self.delete_db, "DELETE")
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = DeleteExecutorCore(
store, self._approval_loader, self._approval_verify, self._tolaria_delete
)
result = exec_core.process_job(job["job_id"], "delete-worker")
self.assertEqual(result["result_code"], "PROVENANCE_MISMATCH")
store.close()
def test_k_outcome_unknown_blind_retry_removed(self):
# Invariante: OUTCOME_UNKNOWN -> kein blinder Retry
def uncertain_save(vault_path, content):
self.http_calls.append(("SAVE", vault_path))
return {"status": "error", "code": "TIMEOUT", "uncertain": True}
store = JobStore(self.save_db, "SAVE")
job = _make_save_job(provenance_hash=self.provenance)
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = SaveExecutorCore(store, self._source_loader, uncertain_save)
exec_core.process_job(job["job_id"], "save-worker")
exec_core.process_job(job["job_id"], "save-worker")
self.assertEqual(len(self.http_calls), 1)
store.close()
def test_l_credential_fail_closed_removed(self):
# Invariante: Credential fehlt -> kein HTTP
def no_cred_save(vault_path, content):
self.http_calls.append(("SAVE", vault_path))
return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False}
store = JobStore(self.save_db, "SAVE")
job = _make_save_job(provenance_hash=self.provenance)
store.create_job(job)
store.mark_ready(job["job_id"])
exec_core = SaveExecutorCore(store, self._source_loader, no_cred_save)
result = exec_core.process_job(job["job_id"], "save-worker")
self.assertEqual(result["result_code"], "CREDENTIAL_MISSING")
store.close()
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.save_db = os.path.join(self.tmpdir, "c5a_save.db")
self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db")
self.http_calls = []
self.approvals = {}
self.content = "hello world"
self.provenance = _sha256(self.content)
def _source_loader(self, commit, object_id):
return self.content
def _tolaria_save(self, vault_path, content):
self.http_calls.append(("SAVE", vault_path))
return {"status": "ok"}
def _approval_loader(self, approval_id):
return self.approvals.get(approval_id)
def _approval_verify(self, approval):
return {"valid": approval.get("signature_valid", False), "reason": "ok"}
def _tolaria_delete(self, vault_path):
self.http_calls.append(("DELETE", vault_path))
return {"status": "ok"}
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,711 @@
# AUTH.3B — SECRET / ENV / RUNTIME INJECTION DESIGN + DEPLOYMENT PRE-FLIGHT
**Status:** DESIGN + VERIFIED DEPLOYMENT PLAN (STRICT READ-ONLY, KEIN produktives Deployment)
**Datum:** 2026-08-27
**Mission Type:** STRICT DESIGN + READ-ONLY DISCOVERY + ISOLATED TESTS
**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) = COMPLETE
> ⚠️ Dies ist ein **Design-/Plan-Dokument**. Es wird NICHTS produktiv deployt,
> kein Token erzeugt, keine ENV/Compose/Coolify geändert, kein Container
> restart/recreate, keine produktive Tolaria-Auth aktiviert.
---
## 1. RUNTIME DISCOVERY (Phase 1) — IST-Zustand
### A) Tolaria Server
| Attribut | Wert |
|---|---|
| Container | `tolaria` |
| Runtime | Node.js 20 / Vite (dev server, `vite.config.ts` middleware) |
| Host Source | `/opt/tolaria/app/tolaria` |
| Container Source | `/app/tolaria` |
| Vault | `/opt/data/tolaria-vault``/app/vault` (Bind-Mount) |
| Compose | `/opt/tolaria/docker-compose.yml` |
| Port | 5173 (produktiv); Repo-`vite.config.ts` deklariert 5202 (dev) |
| Produktiver Source-Basisstand | `b5a5e4bd` (VOR AUTH.2) |
| AUTH.2 deployed? | **NEIN** — Produktion läuft noch OHNE neue Auth (verifiziert: save ohne Token → 200) |
| ENV erwartet (Code) | `TOLARIA_SAVE_TOKEN`, `TOLARIA_DELETE_TOKEN`, `TOLARIA_VAULT_ROOT` (Default `/app/vault`) |
| Fail-closed | JA — `authorizeVaultWrite` Z.120-123: leeres `expected` → 401 "Write scope not configured" |
### B) C5C Writer (SAVE)
| Attribut | Wert |
|---|---|
| Klasse | `C5CPropagator` (`rq_c5c.py` Z.497) |
| Write-Methode | `TolariaClient.write()` (Z.315) → POST `/api/vault/save`, Bearer mit `save_token` |
| Credential-Quelle | NUR explizit injizierter Client (`client=...`); Default `TolariaClient()` credential-frei → fail-closed |
| ENV | `TOLARIA_SAVE_TOKEN` (Konstante Z.100); `rq_c5c.py` liest ENV NICHT selbst |
| CLI-Entrypoint | **KEINER** — kein CLI-Command ruft C5CPropagator mit save_token auf |
| Produktiver Aufrufer | `rq_c5f_resume.py` (untracked) — konstruiert `TolariaClient()` OHNE save_token → mit AUTH.3A fail-closed |
| Runtime | RQ-Container (`06a4a73573d0`), C5-DB `/opt/data/c5c_live/c5a.db` |
### C) C5 DeleteExecutor (DELETE)
| Attribut | Wert |
|---|---|
| Klasse | `DeleteExecutor` (`rq_c5_delete.py`) |
| Write-Methode | `TolariaClient.delete()` → POST `/api/vault/delete`, Bearer mit `delete_token` |
| Credential-Quelle | `_delete_executor` (`rq_c5_cli.py` Z.344): `delete_token=os.environ.get(ENV_TOLARIA_DELETE_TOKEN)` |
| ENV | `TOLARIA_DELETE_TOKEN` (Konstante Z.101) |
| CLI-Entrypoint | `c5-delete-execute`, `c5-delete-replay` (Human-Gated) |
| Runtime | RQ-Container (`06a4a73573d0`), C5-DB `/opt/data/c5c_live/c5a.db` |
### D) Red Queen / Hermes
| Attribut | Wert |
|---|---|
| Container | `06a4a73573d0` (RQ/Hermes) |
| C5-Caller | laufen IM RQ-Container (C5-DB `/opt/data/c5c_live/c5a.db`) |
| Docker-Daemon | NICHT verfügbar aus RQ-Container (kein `/var/run/docker.sock`) |
| VPS-Host-SSH | NICHT verfügbar (Key `red_queen_forgejo` nur für Forgejo-Git-Push) |
| Tolaria-API | read-only erreichbar (`187.124.31.123:5173`) |
### E) Rain / OpenClaw
| Attribut | Wert |
|---|---|
| Ports | 55163 (Alice), 54524 (Matt Addison) |
| Zugriff auf C5-DB | NICHT verifiziert (separate Container) |
| Tolaria-Write-Credential | KEIN (Architektur-Invariante) |
---
## 2. KRITISCHER BEFUND (Phase 7-Vorab)
**SAVE-Caller (C5CPropagator) und DELETE-Caller (DeleteExecutor) laufen im SELBEN
Prozess/Container (RQ-Container `06a4a73573d0`).** Es existiert KEINE getrennte
Runtime-Boundary zwischen SAVE und DELETE. Beide Credentials würden, wenn beide
injiziert würden, im selben Container landen.
**Konsequenz:** Die Credential-Separation (SAVE≠DELETE) ist heute NUR logisch
(verschiedene ENV-Namen, verschiedene Klassen), NICHT technisch als getrennte
Security Boundaries. Das muss in Phase 7 explizit dokumentiert und in Phase 3/4
bewertet werden.
---
## 2b. SECURITY BOUNDARY ANALYSIS (Phase 2) — IST-Zustand
### Runtime-Fakten (verifiziert)
- **RQ/Hermes-Container** = `06a4a73573d0`, User `hermes` (uid 10010, gid 10000).
- **Kein Docker-Socket** im RQ-Container (`/var/run/docker.sock` fehlt) → RQ kann
keine anderen Container inspizieren.
- **Kein VPS-Host-SSH** (Key `red_queen_forgejo` nur für Forgejo-Git-Push) → RQ kann
Host-Dateien (`/opt/tolaria`, Compose) NICHT lesen.
- **Kein `/opt/tolaria`** im RQ-Container-Dateisystem.
- **/proc** im RQ-Container zeigt nur eigene Prozesse (24 PIDs) → kein Cross-Container
/proc-Zugriff.
- **Netzwerk:** RQ erreicht Tolaria intern (`10.0.4.1:5173` → `{"ok":true}`) und
Forgejo (`10.0.4.1:3000`). Tolaria läuft in SEPARATEM Container auf VPS-Host.
- **C5-Caller:** KEIN Daemon, KEIN separater Container. `C5CPropagator` (SAVE) und
`DeleteExecutor` (DELETE) sind Library/CLI, die manuell im RQ-Container aufgerufen
werden (Hermes-Gateway-Prozess). C5-DB `/opt/data/c5c_live/c5a.db` liegt im RQ-Container.
- **ENV im RQ-Container:** `TOLARIA_SAVE_TOKEN` ABSENT, `TOLARIA_DELETE_TOKEN` ABSENT
(korrekt — RQ credential-free). Vorhandene Secrets: `FORGEJO_WRITE_TOKEN`,
`NOTION_API_KEY`, `TELEGRAM_BOT_TOKEN`, `HERMES_SESSION_KEY` (Namen, keine Werte).
### CAN_*_READ_SECRET — SAVE und DELETE (identisch, da gleiche Boundary)
| Akteur | SAVE | DELETE | Weg |
|---|---|---|---|
| **RQ/Hermes** (Container `06a4a73573d0`) | **NEIN** (tatsächlich) | **NEIN** (tatsächlich) | Kein Docker-Socket, kein Host-SSH, kein /proc-Cross, kein /opt/tolaria. ENV ABSENT. |
| **RQ/Hermes** (theoretisch via root) | JA (wenn Host-root) | JA (wenn Host-root) | Host-root kann Container-ENV via `docker inspect`/`docker exec` lesen. |
| **Rain/OpenClaw** (Alice 55163, Matt 54524) | **NEIN** (tatsächlich) | **NEIN** (tatsächlich) | Separate Container, kein Zugriff auf RQ-ENV oder Tolaria-Container. |
| **Anderer Container** (Tolaria selbst) | JA (eigener ENV) | JA (eigener ENV) | Tolaria-Container hält seine eigenen erwarteten Tokens. |
| **Docker-Admin** (VPS-Host) | JA | JA | `docker inspect`/`docker exec` auf Tolaria-Container. |
| **Host-Root** (VPS) | JA | JA | Liest Compose, .env, Container-ENV, Mounts. |
| **C5C Writer** (SAVE-Prozess) | JA (SAVE) | NEIN (kein DELETE) | Erhält nur SAVE-Token via injiziertem Client. |
| **DeleteExecutor** (DELETE-Prozess) | NEIN (kein SAVE) | JA (DELETE) | Erhält nur DELETE-Token via ENV. |
### Security-Boundary-Befund
- **Tolaria-Server** = eigene Boundary (separater Container, VPS-Host).
- **RQ/Hermes** = eigene Boundary (Container `06a4a73573d0`), aber **C5-Caller
(SAVE+DELETE) laufen INNERHALB dieser Boundary**.
- **CRITICAL:** SAVE- und DELETE-Credential würden, wenn beide injiziert würden,
im **selben Container** (`06a4a73573d0`) landen. Es gibt KEINE getrennte
Runtime-Boundary zwischen SAVE-Caller und DELETE-Caller.
- **Konsequenz für Phase 3:** Die Zielarchitektur "SAVE≠DELETE als getrennte
Boundaries" ist mit dem realen Deployment **NICHT vollständig erreichbar**, solange
beide Caller im selben RQ-Container laufen. Die Separation ist NUR logisch
(verschiedene ENV-Namen, verschiedene Klassen, verschiedene CLI-Commands), NICHT
technisch als getrennte Container/Prozesse.
---
## 3b. CREDENTIAL OWNERSHIP MODEL (Phase 3) — Zielarchitektur vs. Realität
### Zielarchitektur (verbindlich aus Freigabe)
- SAVE credential → Tolaria Server + C5C Writer
- DELETE credential → Tolaria Server + C5 DeleteExecutor
- RQ: KEIN SAVE, KEIN DELETE
- Hermes generisch: KEIN SAVE, KEIN DELETE
- Rain: KEIN SAVE, KEIN DELETE
- Kein Master-Token
- SAVE darf DELETE nicht autorisieren; DELETE darf SAVE nicht autorisieren
### Erreichbarkeit mit realem Deployment
**1) Server-seitige Auth (AUTH.2) — ERREICHBAR ✓**
- Tolaria-Server hält SAVE+DELETE-Tokens in seiner eigenen ENV (separater Container).
- RQ/Hermes/Rain haben KEINEN Zugriff auf den Tolaria-Container (kein Docker-Socket,
kein Host-SSH, kein /proc-Cross).
- Server-seitige Scope-Trennung (SAVE≠DELETE) ist im Code verifiziert (AUTH.2).
**2) Caller-seitige Credential-Separation — NICHT ERREICHBAR (HARD STOP-Kandidat) ✗**
- **Ursache:** C5C Writer (SAVE) und DeleteExecutor (DELETE) laufen im **selben
Container** wie RQ/Hermes (`06a4a73573d0`). Es gibt KEINE getrennte Runtime-Boundary.
- Wenn SAVE- und DELETE-Tokens in die ENV dieses Containers injiziert würden, hätte
RQ/Hermes (das im selben Container läuft, gleicher User `hermes`) **technischen
Zugriff auf BEIDE Tokens**.
- Das verletzt die Zielarchitektur "RQ: KEIN SAVE KEIN DELETE" und "Hermes: KEIN
SAVE KEIN DELETE" — unabhängig davon, ob RQ den Zugriff tatsächlich nutzt.
### HARD STOP — exakte Ursache
**Die Zielarchitektur "RQ/Hermes credential-free" ist mit dem realen Deployment
NICHT erreichbar, solange die C5-Caller (SAVE+DELETE) im selben Container wie
RQ/Hermes laufen und ihre Tokens in dessen ENV liegen.**
Notwendige Voraussetzung für AUTH.4 (Architektur-Änderung, NICHT in AUTH.3B):
- **Option A (empfohlen):** C5C Writer und DeleteExecutor in einen **separaten
Container/Prozess** mit eigener Boundary verschieben, der NICHT der RQ-Container
ist. RQ/Hermes ruft diesen Container nur über eine schmale Schnittstelle auf.
- **Option B:** Tokens über einen **separaten Secret-Store** injizieren, auf den nur
der C5-Caller-Prozess (nicht RQ/Hermes) Zugriff hat. Im selben Container mit
gleichem User ist das praktisch nicht durchsetzbar (RQ kann dieselbe Datei lesen).
**Ohne diese Änderung bleibt die Separation NUR logisch** (verschiedene ENV-Namen,
verschiedene Klassen, verschiedene CLI-Commands), NICHT technisch als getrennte
Security Boundaries. Das wird NICHT stillschweigend abgeschwächt — es wird als
HARD STOP für die volle Zielarchitektur dokumentiert.
### Was TROTZDEM sicher erreichbar ist (ohne Architektur-Änderung)
- **Server-seitige Auth (AUTH.2) aktivieren:** Tolaria-Server fail-closed, SAVE≠DELETE
Scope-Trennung. RQ bleibt credential-free (kein Zugriff auf Tolaria-Container).
- **SAVE-Aktivierung:** C5C Writer erhält SAVE-Token. ABER: da im RQ-Container,
hätte RQ technisch Zugriff → verletzt "RQ credential-free" für SAVE.
- **DELETE-Aktivierung:** DeleteExecutor erhält DELETE-Token. ABER: da im RQ-Container,
hätte RQ technisch Zugriff → verletzt "RQ credential-free" für DELETE.
### Fazit Phase 3
- **SERVER_AUTH_DEPLOYMENT:** ERREICHBAR (RQ bleibt credential-free ggü. Tolaria).
- **SAVE_ACTIVATION / DELETE_CREDENTIAL_ACTIVATION:** BLOCKED bis C5-Caller in
getrennte Boundary verschoben ODER Secret-Injection RQ-undurchdringbar ist.
- **Kein Master-Token:** ERREICHBAR (AUTH.2/AUTH.3A verifiziert, kein Master-Key).
- **SAVE≠DELETE Scope-Trennung:** Server-seitig ERREICHBAR; Caller-seitig nur logisch.
---
## 4b. SECRET STORAGE OPTIONS (Phase 4) — Bewertung
### Kontext: Was existiert im realen Stack?
- **Coolify-Suite** ist auf dem VPS installiert (infrastructure-handbook Z.52).
- **Exakte Tolaria-Provisionierung (Compose vs Coolify) ist aus RQ-Sicht UNKNOWN**
(kein Docker-Socket, kein Host-SSH). CURRENT_STATE Z.27: "UNKNOWN: Container-Name/
Image-Konfiguration, Env-Variablen, Mounts, Volumes — nur über Host-/Docker-Zugriff
ermittelbar, der nicht verfügbar ist."
- **Kein separater Secret-Store** (kein Vault/Consul/SOPS/age) im Stack nachweisbar.
- **Kein Docker-Secrets-Mechanismus** im Stack nachweisbar (kein Swarm).
- Tolaria-Server liest ENV via `process.env.TOLARIA_SAVE_TOKEN` (vite.config.ts).
### Optionen-Bewertung (SAVE und DELETE, identisch)
| Kriterium | A) Plain Compose ENV | B) .env-Datei | C) Docker Secrets | D) read-only Secret-Datei | E) Coolify Secret Mgmt | F) separater Secret-Store |
|---|---|---|---|---|---|---|
| SECURITY | Schwach (ENV in inspect) | Mittel (Datei, chmod) | Stark (nur im Container) | Mittel (Datei, chmod) | Stark (Coolify-UI) | Stark |
| VISIBILITY | `docker inspect` sichtbar | Datei, nicht inspect | Nicht in inspect | Datei, nicht inspect | Nicht in inspect | Nicht in inspect |
| DOCKER_INSPECT_EXPOSURE | **JA** | NEIN | NEIN | NEIN | NEIN | NEIN |
| HOST_EXPOSURE | JA (root liest) | JA (root liest) | JA (root liest) | JA (root liest) | JA (root liest) | JA (root liest) |
| RQ_EXPOSURE | JA (wenn im RQ-Container) | JA (wenn im RQ-Container) | NEIN (nur Ziel-Container) | JA (wenn im RQ-Container) | NEIN | NEIN |
| ROTATION | Restart nötig | Restart nötig | Restart nötig | Restart nötig | Restart nötig | Restart nötig |
| REVOCATION | ENV entfernen + Restart | Datei löschen + Restart | Secret entfernen + Restart | Datei löschen + Restart | ENV entfernen + Restart | Store-Update + Restart |
| RESTART_BEHAVIOR | ENV bleibt | Datei bleibt | Secret bleibt | Datei bleibt | ENV bleibt | Store bleibt |
| BACKUP_BEHAVIOR | ENV nicht in Backup | Datei evtl. in Backup | Secret nicht in Backup | Datei evtl. in Backup | ENV nicht in Backup | Store in Backup |
| OPERATIONAL_COMPLEXITY | Niedrig | Niedrig | Mittel (Swarm nötig) | Niedrig | Mittel | Hoch |
| COMPATIBILITY | Hoch (Vite liest ENV) | Hoch | Niedrig (kein Swarm) | Hoch (Datei lesen) | Mittel (Coolify vorhanden) | Niedrig (nicht vorhanden) |
| BLAST_RADIUS | Hoch (inspect) | Mittel | Niedrig | Mittel | Niedrig | Niedrig |
### Empfehlung für UNSEREN Stack
**Minimal sichere Variante: B) .env-Datei (oder D) read-only Secret-Datei) mit
strikten Permissions, injiziert in die Tolaria-Container-ENV.**
Begründung:
- **A (Plain Compose ENV) ist abzulehnen:** `docker inspect` würde die Tokenwerte
offenlegen (DOCKER_INSPECT_EXPOSURE=JA). Das verletzt die Anforderung "kein Secret
in docker inspect".
- **C (Docker Secrets) ist nicht kompatibel:** erfordert Docker Swarm, das im Stack
nicht nachweisbar ist.
- **F (separater Secret-Store) ist nicht vorhanden:** keine neue Infrastruktur
erfinden (Freigabe-Anforderung).
- **E (Coolify Secret Mgmt):** Coolify ist vorhanden, aber die exakte Tolaria-
Provisionierung ist UNKNOWN. Falls Tolaria über Coolify deployed wird, ist Coolify
Secret Management die natürliche Wahl (nicht in inspect, Rotation via UI).
- **B/D sind die robuste Default-Empfehlung:** `.env`-Datei mit `chmod 600`, vom
Compose/Coolify in die Container-ENV injiziert. Tokenwerte erscheinen NICHT in
`docker inspect` (nur der ENV-Name, nicht der Wert, wenn via `env_file` injiziert).
**WICHTIG (Phase 3-Kopplung):** Unabhängig von der Storage-Option bleibt der
HARD STOP bestehen: Solange die C5-Caller im RQ-Container laufen, würde jede
Injektion in dessen ENV RQ technischen Zugriff geben. Die Storage-Empfehlung gilt
daher primär für den **Tolaria-Server-Container** (eigene Boundary, RQ-fern).
---
## 5b. TOKEN GENERATION CONTRACT (Phase 5) — NUR DESIGN, KEINE echten Tokens
> ⚠️ Dies ist ein **Design-Vertrag**. Es wird KEIN echtes Token erzeugt, kein Wert
> angezeigt, nichts in ENV/Compose/Logs/Telegram/Repo geschrieben.
### Kryptographische Zufallsquelle
- **`/dev/urandom`** (Linux) oder `secrets.token_urlsafe()` (Python) — kryptographisch
sicher, nicht-deterministisch.
- NICHT: `random`-Modul, `time`-basiert, UUID (nicht krypto-sicher genug für Auth).
### Mindestentropie
- **≥ 256 Bit** (32 Bytes) pro Token. Empfehlung: 32 Bytes aus `/dev/urandom`,
base64url-kodiert → 43 Zeichen.
### Format / Länge
- **Format:** base64url (URL-sicher, kein `+`/`/`/`=`), 43 Zeichen bei 32 Bytes.
- **Länge:** 43 Zeichen (256 Bit Entropie).
- **Keine semantischen Token-Präfixe** (kein `save-`, `delete-`, `tolaria-` im Wert) —
Präfixe wären unnötige Information und könnten Scope-Leaks erzeugen. Der Scope wird
durch die ENV-Variable bestimmt, nicht durch den Tokenwert.
### Unabhängigkeit
- **SAVE und DELETE unabhängig generiert** (zwei separate `/dev/urandom`-Ziehungen).
- **Niemals gleicher Wert** (bei 256 Bit Entropie kollisionsfrei).
- **Keine Ableitung SAVE→DELETE** (kein Hash/Substring/Transformation).
- **Kein Master-Key** (kein gemeinsamer Seed, kein hierarchisches Schema).
### Nicht-Speicherung / Nicht-Ausgabe
- **Keine Shell-History** (kein `echo $TOKEN`, kein Token in interaktiven Befehlen).
- **Keine Ausgabe in Telegram / Chat / Logs / FINAL REPORT.**
- Token wird NUR in die Ziel-ENV/Secret-Datei geschrieben (Tolaria-Server-Container),
nie in Git, nie in Reports, nie in Test-Fixtures.
### Wer die Tokens bei AUTH.4 erzeugen darf
- **Nur Christian (Mensch)** — manuell auf dem VPS-Host, via `/dev/urandom` oder
`openssl rand -base64 32`, direkt in die Secret-Datei/Coolify-ENV geschrieben.
- **NICHT RQ/Hermes/Rain** — kein Agent erzeugt oder sieht die echten Tokens.
- Erzeugung erfolgt im Rahmen der AUTH.4-Deployment-Sequenz (Phase 9), nicht in
AUTH.3B.
---
## 6b. SERVER INJECTION CONTRACT (Phase 6) — AUTH.2 im Code verifiziert
### Exakte ENV-Namen (im Code verifiziert, `vite.config.ts` Z.35-39)
- `TOLARIA_VAULT_ROOT` → Default `/app/vault`
- `TOLARIA_SAVE_TOKEN``saveToken` (Scope SAVE)
- `TOLARIA_DELETE_TOKEN``deleteToken` (Scope DELETE)
### Verhalten (im Code verifiziert, `vault-write-guard.ts` Z.112-131)
| Feld | Wert |
|---|---|
| SERVER_SAVE_ENV | `TOLARIA_SAVE_TOKEN` |
| SERVER_DELETE_ENV | `TOLARIA_DELETE_TOKEN` |
| MISSING_SAVE_BEHAVIOR | `expected.length === 0` → 401 "Write scope not configured" (fail-closed) |
| MISSING_DELETE_BEHAVIOR | dito (fail-closed) |
| EMPTY_TOKEN_BEHAVIOR | `|| ''` → leeres expected → 401 (fail-closed) |
| WRONG_SCOPE_BEHAVIOR | `constantTimeEqual(extracted, expected)` → falscher Scope → 401 "Invalid credentials" |
### Fail-closed bestätigt
- **Kein "Auth disabled because ENV missing".** Fehlende/leere Token-Konfiguration
→ mutierende Endpoints bleiben DENIED (401).
- Scope-Trennung: SAVE-Token autorisiert NUR SAVE; DELETE-Token NUR DELETE.
- Const-time-Vergleich (`constantTimeEqual`) gegen Timing-Angriffe.
- Auth-before-Mutation: bei Auth-Fehler wird Response gesendet, KEINE Mutation.
### Fazit Phase 6
- **SERVER_FAIL_CLOSED_STATUS = ENFORCED** (im Code verifiziert).
- Server erwartet exakt `TOLARIA_SAVE_TOKEN` + `TOLARIA_DELETE_TOKEN`.
- Kein Master-Token, keine Scope-Abschwächung.
---
## 7b. CALLER INJECTION CONTRACT (Phase 7) — AUTH.3A im Code verifiziert
### ENV-Contract (im Code verifiziert, `rq_c5c.py` Z.100-101)
- `ENV_TOLARIA_SAVE_TOKEN = "TOLARIA_SAVE_TOKEN"`
- `ENV_TOLARIA_DELETE_TOKEN = "TOLARIA_DELETE_TOKEN"`
### Credential-Verwendung (im Code verifiziert)
| Caller | ENV | Verwendung | Scope |
|---|---|---|---|
| C5C Writer (`C5CPropagator`) | `TOLARIA_SAVE_TOKEN` | `write()` Z.322: `self._require_token(self.save_token, "SAVE")` | SAVE |
| DeleteExecutor | `TOLARIA_DELETE_TOKEN` | `delete()` Z.352: `self._require_token(self.delete_token, "DELETE")` | DELETE |
| read/list | — | KEIN Credential | READ |
### Verifizierte Invarianten
- **SAVE-Prozess bekommt DELETE nicht:** `write()` nutzt NUR `self.save_token`.
- **DELETE-Prozess bekommt SAVE nicht:** `delete()` nutzt NUR `self.delete_token`.
- **read/list brauchen nichts:** `read()`/`list()` senden kein Bearer.
- **fehlendes Token => kein HTTP Write:** `_require_token` (Z.300-313) bricht lokal ab
(RC_AUTH_FAILURE), HTTP wird NICHT aufgerufen (fail-closed, AUTH.1 §4).
- **Retry behält Credential:** Client-Instanz hält Token über Retries hinweg.
- **Replay behält Credential:** `c5-delete-replay` nutzt denselben `_delete_executor`.
- **Token wird nicht persistiert:** kein Token in DB/Logs/Reports (AUTH.3A verifiziert).
### KRITISCHER BEFUND (Runtime-Separation)
- **Sind C5C Writer und DeleteExecutor heute getrennte Runtime-Prozesse/Boundaries?
NEIN.**
- Beide laufen im **selben Container** (`06a4a73573d0`, RQ/Hermes) und werden als
Library/CLI im selben Hermes-Gateway-Prozess aufgerufen.
- **Konsequenz:** Die Credential-Separation ist NUR logisch (verschiedene ENV-Namen,
verschiedene Klassen, verschiedene CLI-Commands). Wenn beide Tokens in die ENV
dieses Containers injiziert würden, hätte RQ/Hermes technischen Zugriff auf BEIDE.
- **Das wird NICHT als technische Separation behauptet.** Es ist explizit dokumentiert:
CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY (nicht technisch).
### Fazit Phase 7
- **CALLER_FAIL_CLOSED_STATUS = ENFORCED** (im Code verifiziert).
- **CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY** (nicht technisch, da gleiche Boundary).
- Least Privilege im Code korrekt; Runtime-Separation fehlt (HARD STOP aus Phase 3).
---
## 8b. HUMAN APPROVAL INTERACTION (Phase 8) — AUTH4_WITH_OPEN_APPROVAL_GAP
### Kontext
- **HUMAN_APPROVAL_AUTHENTICITY_GAP = OPEN** (aus AUTH.3A, NICHT repariert).
Die DELETE-Approval erfüllt Invarianten AN, aber die Provenance ist nicht
kryptographisch an Christian gebunden (frei wählbares CLI-Argument
`--approved-by`, `rq_c5_cli.py` Z.576).
- **DELETE-Execution (AUTH.3A, `rq_c5_delete.py` Z.192-251):** 4 Pre-Gates:
1. ObjectChange existiert + operation == DELETE
2. Commit-Status == ST_DELETE_APPROVED
3. Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad) ← **Human Gate**
4. Pre-Delete-Drift-Check (Ziel existiert noch)
### Wie interagiert das DELETE-Token mit dem Human Gate?
- Das DELETE-Token (AUTH.2/AUTH.3A) ist eine **zusätzliche, unabhängige Bedingung**
auf Transport-Ebene: `delete()` sendet Bearer DELETE-Token; Server verweigert ohne
gültiges Token (401).
- Das Human Approval Gate (Gate 3) ist eine **separate Bedingung auf
Anwendungs-Ebene**: `execute()` verweigert ohne persistierte, exakt passende
Approval.
- **DELETE ist nur möglich, wenn BEIDES erfüllt ist:**
A) gültiges DELETE-Credential (Server-Auth) UND
B) bestehendes C5 Human Approval Gate (Gate 3).
### Verschärft AUTH.4 das Authenticity-Gap?
- **NEIN.** AUTH.4 fügt eine zusätzliche Hürde (DELETE-Token) hinzu, die das
bestehende Gap NICHT verschärft, sondern die DELETE-Ausführung zusätzlich
absichert.
- Das Gap (Provenance nicht kryptographisch an Christian gebunden) bleibt bestehen,
wird aber durch das DELETE-Token NICHT schwächer. Ein Angreifer, der das
Authenticity-Gap ausnutzt (frei wählbares `--approved-by`), bräuchte ZUSÄTZLICH
das DELETE-Token, um tatsächlich zu löschen.
- **Kein Risiko, dass durch das DELETE-Token ein schwächeres Human Gate akzeptiert
wird.** Das Human Gate (Gate 3) bleibt unverändert ENFORCED.
### Klassifikation
**AUTH4_WITH_OPEN_APPROVAL_GAP = SAFE_FOR_SERVER_AUTH_ONLY**
Begründung:
- Die Einführung des DELETE-Tokens (Server-Auth) verschärft das Authenticity-Gap
NICHT — es fügt eine unabhängige, zusätzliche Hürde hinzu.
- **ABER:** DELETE_OPERATION_ACTIVATION (tatsächliches Löschen) sollte erst nach
Schließung des Gaps freigegeben werden, um das Risiko eines kombinierten Angriffs
(Gap + gestohlenes Token) zu minimieren.
- **SAFE_FOR_SERVER_AUTH_ONLY** bedeutet: Server-seitige Auth (AUTH.2) kann sicher
aktiviert werden, ohne das Gap zu verschärfen. Die DELETE-Operation selbst bleibt
bis zur Gap-Schließung zurückhaltend zu behandeln (siehe Phase 14 GO/NO-GO).
---
## 9b. DEPLOYMENT SEQUENCE DESIGN (Phase 9) — AUTH.4 atomar, KEIN Big-Bang
> ⚠️ NUR DESIGN. KEINE AUSFÜHRUNG in AUTH.3B.
### Kernfrage: Caller zuerst vs Server zuerst?
**Server zuerst (AUTH.2 aktivieren), dann Caller (AUTH.3A).**
Begründung (kein Zeitfenster mit ungeschützten Writes):
- Wenn der **Caller zuerst** das SAVE-Token erhält, aber der **Server noch ohne
Auth** läuft, dann sendet der Caller zwar ein Bearer-Token, aber der Server
ignoriert es (Produktion läuft aktuell OHNE AUTH.2) → **Writes bleiben
ungeschützt** (ungültiges Zeitfenster).
- Wenn der **Server zuerst** AUTH.2 aktiviert (fail-closed), dann verweigert er
ALLE Writes ohne gültiges Token. In diesem Moment haben die Caller noch KEIN
Token → **legitime C5-Writes fallen kurzzeitig aus** (kontrolliertes, kurzes
Fenster). Das ist akzeptabel, weil es FAIL-CLOSED ist (kein ungeschützter Write).
- **Kein Zeitfenster, in dem Writes ungeschützt bleiben:** Server-Auth zuerst
schließt das Loch sofort; Caller-Token werden danach injiziert.
- **Kein Zeitfenster, in dem DELETE ohne Human Gate möglich wird:** DELETE-Token
wird erst injiziert, wenn das Human Gate (Gate 3) bereits ENFORCED ist (AUTH.3A
Code ist deployed). DELETE bleibt doppelt abgesichert.
### Atomare AUTH.4-Reihenfolge (jeder Schritt einzeln, mit Gate)
| # | Schritt | Aktion | Gate/Verifikation |
|---|---|---|---|
| 1 | **PRECHECK** | Produktiver Source == Forgejo `edcb4902`; AUTH.2/AUTH.3A Code deployed; kein Token in ENV | `git diff` leer, ENV-Namen ABSENT |
| 2 | **BACKUP** | Vault + Compose + aktuelle ENV-Namen sichern (KEINE Tokenwerte) | Backup-Datei existiert |
| 3 | **TOKEN_GENERATION** | Christian erzeugt SAVE+DELETE-Token (Phase 5 Contract), schreibt in Secret-Datei/Coolify | Token in Ziel-ENV, NICHT in Git/Logs |
| 4 | **SERVER_SECRET_INJECTION** | `TOLARIA_SAVE_TOKEN` + `TOLARIA_DELETE_TOKEN` in Tolaria-Container-ENV (via .env/Coolify) | ENV-Namen PRESENT (Werte nicht prüfen) |
| 5 | **CALLER_SECRET_INJECTION** | SAVE-Token an C5C Writer, DELETE-Token an DeleteExecutor (NUR nach Boundary-Fix, Phase 3) | Getrennte Boundaries ODER HARD STOP |
| 6 | **CODE_DEPLOY** | AUTH.2-Source (edcb4902) in Produktion deployen (Container-Restart) | Server startet, Read funktioniert |
| 7 | **SERVER_RESTART** | Tolaria-Container neu starten (liest neue ENV) | Healthcheck OK |
| 8 | **READ_SMOKE** | `GET /api/vault/ping`, `/list`, `/content` ohne Token → 200 | Read funktioniert |
| 9 | **UNAUTH_WRITE_NEGATIVE_TEST** | `POST /save` ohne Token → 401 (fail-closed) | Write DENIED |
| 10 | **WRONG_SCOPE_NEGATIVE_TEST** | `POST /save` mit DELETE-Token → 401; `POST /delete` mit SAVE-Token → 401 | Scope-Trennung |
| 11 | **AUTHORIZED_SAVE_TEST** | `POST /save` mit SAVE-Token → 200 (isolierte Fixture) | SAVE funktioniert |
| 12 | **DELETE_GATE_NEGATIVE_TEST** | `POST /delete` mit DELETE-Token aber OHNE Human Approval → DENIED | Human Gate ENFORCED |
| 13 | **AUTHORIZED_DELETE_TEST** | `POST /delete` mit DELETE-Token + Human Approval → 200 (NUR falls freigabefähig) | DELETE funktioniert |
| 14 | **POST_DEPLOY_CHECK** | Read + Write + Scope + kein Token in Logs/inspect | Alle Checks grün |
| 15 | **FRESH_CHECKER** | Unabhängiger Checker verifiziert alle Gates | PASS |
### Wichtige Design-Entscheidungen
- **Kein Big-Bang:** Jeder Schritt hat ein eigenes Gate; bei Fehler → Rollback
(Phase 10), nicht weiter.
- **Schritt 5 (CALLER_SECRET_INJECTION) ist BLOCKED** bis die Boundary-Frage aus
Phase 3 gelöst ist (C5-Caller in getrennten Container ODER RQ-undurchdringbare
Injektion). Ohne das bleibt RQ credential-free NICHT erreichbar.
- **Schritt 13 (AUTHORIZED_DELETE_TEST) ist CONDITIONALLY_READY** — nur falls
DELETE-Operation freigabefähig ist (siehe Phase 14, Gap-Berücksichtigung).
- **Server zuerst** minimiert das ungeschützte Write-Fenster auf null.
---
## 10b. ROLLBACK DESIGN (Phase 10) — FAIL CLOSED, Write-Degraded-Mode
> ⚠️ NUR DESIGN. KEINE AUSFÜHRUNG in AUTH.3B.
### Grundprinzip
- **FAIL CLOSED bevorzugen.** Bei Credential-/Auth-Problem NICHT automatisch auf
unauthentifizierte Writes zurückrollen.
- **Ein Rollback darf NICHT bedeuten:** "Auth ausschalten und unauthentifizierte
Writes wieder erlauben", wenn dadurch die bereits identifizierte CRITICAL Gap
(Produktion läuft aktuell OHNE Auth) wieder geöffnet wird.
- **Bevorzugter Security-Rollback = WRITE_DEGRADED_MODE:**
- **READ AVAILABLE** (Read-Endpunkte funktionieren weiter)
- **WRITE DISABLED** (mutierende Endpunkte bleiben DENIED)
### Exakte Rollback-Pfade
| Fall | Symptom | Rollback-Aktion | Security-Position |
|---|---|---|---|
| **A) Server startet nicht** | Container-Crash nach AUTH.2-Deploy | Source auf `b5a5e4bd` (Basisstand) zurück; ENV-Tokens entfernen; Restart | FAIL CLOSED (kein Write ohne Auth) |
| **B) Read-Endpunkte kaputt** | `/ping`, `/list`, `/content` 500 | Source auf `b5a5e4bd` zurück; ENV-Tokens entfernen; Restart | FAIL CLOSED |
| **C) C5C SAVE schlägt fehl** | SAVE-Writes 401/500 | SAVE-Token prüfen/erneuern; NICHT Auth deaktivieren | WRITE_DEGRADED (SAVE disabled, READ available) |
| **D) DELETE-Caller schlägt fehl** | DELETE-Writes 401/500 | DELETE-Token prüfen/erneuern; NICHT Human Gate deaktivieren | WRITE_DEGRADED (DELETE disabled, READ available) |
| **E) Token falsch injiziert** | Wrong-Scope 401 | Token korrekt injizieren; NICHT Auth deaktivieren | FAIL CLOSED |
| **F) Token-Leak vermutet** | Token in Logs/inspect/Repo | Token SOFORT rotieren (Phase 11); NICHT Auth deaktivieren | FAIL CLOSED |
| **G) Falscher Scope** | SAVE-Token autorisiert DELETE (o. umgekehrt) | Scope-Trennung prüfen; Token neu generieren | FAIL CLOSED |
| **H) Regression nach Restart** | Read/Write unerwartet | Source auf `b5a5e4bd` zurück; ENV-Tokens entfernen; Restart | FAIL CLOSED |
### WRITE_DEGRADED_MODE (bevorzugter Security-Rollback)
- **READ AVAILABLE:** `/api/vault/ping`, `/list`, `/content`, `/all-content`,
`/entry`, `/search` funktionieren (kein Token nötig).
- **WRITE DISABLED:** `/api/vault/save`, `/rename`, `/rename-filename`, `/delete`,
`/command` bleiben DENIED (401) — fail-closed, weil Token-Konfiguration entfernt
oder ungültig ist.
- **Wie erreicht:** ENV-Tokens aus Tolaria-Container entfernen (oder auf ungültig
setzen) + Restart. Server bleibt fail-closed (leeres expected → 401).
- **WICHTIG:** Das ist KEIN "Auth ausschalten". Es ist "Auth auf DENIED-ALL
setzen" — Writes sind blockiert, nicht ungeschützt.
### Warum NICHT auf unauthentifizierte Writes zurückrollen
- Die Produktion läuft aktuell OHNE Auth (CRITICAL Gap, verifiziert in Phase 1).
- Ein Rollback auf "Auth aus" würde dieses Gap wieder öffnen und Writes ungeschützt
lassen.
- WRITE_DEGRADED_MODE hält Writes geschützt (DENIED), während Read verfügbar bleibt
— das ist die sichere Zwischenposition bis zur Fehlerbehebung.
---
## 11b. TOKEN ROTATION / REVOCATION (Phase 11) — SAVE/DELETE getrennt
> ⚠️ NUR DESIGN. KEINE AUSFÜHRUNG in AUTH.3B.
### Unterstützt AUTH.2 nur einen Token pro Scope?
- **JA.** `VaultAuthConfig` (`vite.config.ts` Z.36-39) hält `saveToken` und
`deleteToken` als **einzelne Strings** (kein Array, keine Multi-Token-Unterstützung).
- `authorizeVaultWrite` (Z.120-126) vergleicht gegen genau EINEN erwarteten Token
pro Scope.
- **Konsequenz:** Zero-downtime-Rotation (zwei gültige Tokens gleichzeitig) ist
mit AUTH.2 NICHT möglich. Rotation erfordert ein kurzes Write-Downtime-Fenster.
### SAVE-Rotation (minimales Write-Downtime-Fenster)
1. Neues SAVE-Token erzeugen (Phase 5 Contract).
2. Neues Token in Tolaria-Container-ENV schreiben (ersetzt altes).
3. Tolaria-Container neu starten (liest neues Token).
4. C5C Writer erhält neues SAVE-Token (nach Boundary-Fix, Phase 3).
5. Altes SAVE-Token ist sofort ungültig (Server vergleicht nur gegen neues).
6. **Write-Downtime-Fenster:** zwischen Schritt 3 und 4 sendet C5C Writer noch
altes Token → 401. Fenster = Zeit bis C5C Writer aktualisiert ist.
7. **Verifikation:** `POST /save` mit neuem Token → 200; mit altem → 401.
### DELETE-Rotation (analog, getrennt)
1. Neues DELETE-Token erzeugen.
2. In Tolaria-Container-ENV schreiben (ersetzt altes).
3. Tolaria-Container neu starten.
4. DeleteExecutor erhält neues DELETE-Token.
5. Altes DELETE-Token sofort ungültig.
6. **Verifikation:** `POST /delete` mit neuem Token + Human Approval → 200; mit
altem → 401.
### Laufende Retries / queued Jobs
- **Retries:** Ein laufender Retry mit altem Token schlägt nach Rotation fehl (401).
Der Retry muss das neue Token verwenden (Client-Instanz neu konstruieren).
- **Queued Jobs:** C5-Queue-Einträge, die vor Rotation geplant wurden, verwenden
beim Ausführen das aktuelle Token (Client wird zur Laufzeit konstruiert). Nach
Rotation nutzen sie das neue Token.
- **Empfehlung:** Rotation in einem Wartungsfenster durchführen, wenn keine
kritischen Writes anstehen.
### DELETE-Revocation (unabhängig von SAVE)
- **DELETE-Revocation muss unabhängig von SAVE möglich sein:** Ja — DELETE-Token
ist eine separate ENV-Variable (`TOLARIA_DELETE_TOKEN`). Entfernen/ersetzen
dieser Variable + Restart widerruft DELETE, ohne SAVE zu beeinflussen.
- **SAVE-Revocation analog:** `TOLARIA_SAVE_TOKEN` entfernen/ersetzen + Restart
widerruft SAVE, ohne DELETE zu beeinflussen.
- **Revocation = sofort wirksam** nach Container-Restart (Server liest ENV beim
Start; kein Cache).
### Welche Komponenten müssen restart/reload?
- **Tolaria-Server-Container:** Restart nötig (liest ENV beim Start).
- **C5C Writer / DeleteExecutor:** Client-Instanz neu konstruieren (neues Token).
Kein Container-Restart nötig, wenn Token via ENV/Config injiziert wird und der
Prozess neu gestartet wird.
### Wie wird Rotation verifiziert?
- **Positiv:** Write mit neuem Token → 200.
- **Negativ:** Write mit altem Token → 401 (fail-closed).
- **Scope:** SAVE-Token autorisiert nicht DELETE und umgekehrt.
- **Kein Token-Leak:** neues Token nicht in Logs/inspect/Repo.
---
## 12b. BACKUP / RECOVERY (Phase 12) — Secret-Handling
> ⚠️ NUR DESIGN. KEINE Backup-Konfiguration ändern in AUTH.3B.
### Werden Secret-Werte in normalen Backups erfasst?
- **NEIN.** Das Tolaria-Backup-Skript (`/opt/data/bin/tolaria_backup.py`) sichert
NUR Vault-Content (`.md`-Dateien) und App-Source über die read-only API
(`/api/vault/list`, `/api/vault/content`). Es sichert KEINE ENV-Variablen,
KEINE Compose-Datei, KEINE Secret-Dateien.
- **Sollten sie enthalten sein?** NEIN. Secrets gehören NICHT in Backups. Sie
werden separat (außerhalb des Backup-Pfads) verwaltet und bei Restore neu
injiziert.
### Wie wird Disaster Recovery durchgeführt?
1. Vault-Content aus Backup wiederherstellen (via `tolaria_backup.py` oder
Vault-Git-Restore).
2. App-Source aus Forgejo (`edcb4902`) neu deployen (rebuildbar, Forgejo=SoT).
3. **Secrets NEU injizieren** (nicht aus Backup): Christian erzeugt frische
SAVE/DELETE-Tokens (Phase 5 Contract) und schreibt sie in die Container-ENV.
### Müssen Tokens nach Restore rotiert werden?
- **JA, zwingend.** Nach einem Disaster Recovery werden frische Tokens erzeugt
(nicht die alten aus dem Backup). Das verhindert, dass alte, möglicherweise
kompromittierte Tokens weiterleben.
### Können alte Backups alte gültige Tokens enthalten?
- **NEIN** (aktuell): Das Backup-Skript sichert keine Secrets. Aber falls in
Zukunft ein Backup die Compose-Datei oder ENV mit aufnimmt, könnten alte Tokens
enthalten sein.
### Wie verhindern wir Secret-Restore aus veraltetem Backup?
- **Backup-Skript sichert keine Secrets** (verifiziert, Z.17 "Keine Secrets im
Manifest/Log").
- **Restore-Prozedur injiziert IMMER frische Tokens** (nie aus Backup).
- **Empfehlung:** Falls ein Backup je Compose/ENV aufnimmt, Secrets vor dem
Backup strikt ausschließen (`.gitignore`-artige Exklusion) und nach Restore
rotieren.
### Empfehlung (dokumentiert, KEINE Änderung)
- Secrets NIE in Backups aufnehmen.
- Nach jedem Restore frische Tokens erzeugen.
- Backup-Manifest/Logs secret-frei halten (bereits der Fall).
---
## 13b. ISOLATED DEPLOYMENT TEST PLAN (Phase 13) — AUTH.4
> ⚠️ NUR TESTPLAN. KEINE produktiven Mutationsprobes in AUTH.3B.
> Alle Tests werden in AUTH.4 in isolierter Umgebung (Fixture-Vault, synthetische
> Tokens) ausgeführt, NICHT gegen Produktion.
| ID | Test | Erwartung | Typ |
|---|---|---|---|
| T1 | READ ohne Token | `/ping`, `/list`, `/content` → 200 | Positiv |
| T2 | SAVE ohne Token | `POST /save` → 401 (fail-closed) | Negativ |
| T3 | SAVE wrong token | `POST /save` mit falschem Token → 401 | Negativ |
| T4 | SAVE DELETE-token | `POST /save` mit DELETE-Token → 401 (Scope) | Negativ |
| T5 | SAVE korrektes SAVE-token | `POST /save` mit SAVE-Token → 200 | Positiv |
| T6 | DELETE ohne Token | `POST /delete` → 401 (fail-closed) | Negativ |
| T7 | DELETE wrong token | `POST /delete` mit falschem Token → 401 | Negativ |
| T8 | DELETE SAVE-token | `POST /delete` mit SAVE-Token → 401 (Scope) | Negativ |
| T9 | DELETE korrektes Token, ohne Human Approval | → DENIED (Gate 3) | Negativ |
| T10 | DELETE Approval, aber ohne Token | → 401 (fail-closed) | Negativ |
| T11 | DELETE Token + Approval | → 200 NUR falls ausdrücklich freigegeben | Positiv |
| T12 | missing server ENV | Server startet, Writes DENIED (fail-closed) | Negativ |
| T13 | missing caller ENV | Caller bricht vor HTTP ab (fail-closed) | Negativ |
| T14 | Restart erhält Security State | Nach Restart weiterhin fail-closed | Positiv |
| T15 | kein Token in Logs | Logs secret-frei | Negativ |
| T16 | kein Token in inspect | `docker inspect` secret-frei (soweit Architektur) | Negativ |
| T17 | kein Token in Repo | Git-History secret-frei | Negativ |
| T18 | kein Token im Vault | Vault-Content secret-frei | Negativ |
| T19 | kein Token in DB | C5-DB secret-frei | Negativ |
| T20 | Retry sicher | Retry behält Credential, kein Leak | Positiv |
| T21 | Replay sicher | Replay behält Credential, kein Leak | Positiv |
| T22 | READ während Write-Degraded-Mode | Read 200, Write DENIED | Positiv |
| T23 | SAVE/DELETE getrennt widerrufbar | Revocation eines Scopes beeinflusst anderen nicht | Positiv |
| T24 | Path traversal weiterhin denied | `../` → 401/400 | Negativ |
| T25 | Symlink escape weiterhin denied | Symlink → 401/400 | Negativ |
| T26 | `/api/vault/command` kann Auth nicht umgehen | Command ohne Token → 401 | Negativ |
### Test-Ausführungsregeln (AUTH.4)
- Alle Tests in isolierter Umgebung (Fixture-Vault, synthetische Tokens
`auth4-...`), NICHT gegen Produktion.
- T11 (DELETE Token + Approval) NUR wenn DELETE-Operation freigabefähig ist
(siehe Phase 14).
- Keine produktiven Mutationsprobes in AUTH.3B.
---
## 14b. AUTH.4 GO/NO-GO MATRIX (Phase 14)
> ⚠️ NUR BEWERTUNG. KEINE Aktivierung in AUTH.3B.
| Punkt | Status | Konkrete Blocker |
|---|---|---|
| **SERVER_AUTH_DEPLOYMENT** | **CONDITIONALLY_READY** | AUTH.2-Code (edcb4902) ist fertig + getestet (70/70, Fresh Checker 17/17). Blocker: produktiver Source muss == Forgejo `edcb4902` sein; ENV-Tokens müssen injiziert werden; Container-Restart nötig. Kein Code-Blocker. |
| **SAVE_ACTIVATION** | **CONDITIONALLY_READY** | AUTH.3A-Code (a11c1bb) fertig + getestet (19/19, 318/318). Blocker: SAVE-Token muss an C5C Writer injiziert werden; **Boundary-Frage (Phase 3) muss gelöst sein** — sonst hätte RQ Zugriff auf SAVE-Token. |
| **DELETE_CREDENTIAL_ACTIVATION** | **BLOCKED** | DELETE-Token-Injektion an DeleteExecutor erfordert getrennte Boundary (Phase 3). Aktuell laufen SAVE+DELETE im selben Container → RQ hätte Zugriff auf BEIDE Tokens. **HARD STOP bis Boundary-Fix.** |
| **DELETE_OPERATION_ACTIVATION** | **BLOCKED** | Zusätzlich zum Boundary-Fix: HUMAN_APPROVAL_AUTHENTICITY_GAP ist OPEN. DELETE-Operation erst nach Gap-Schließung freigeben (kombinierter Angriff Gap+Token). |
| **HERMES_READ_ONLY_AUTONOMY** | **CONDITIONALLY_READY** | Read-Endpunkte brauchen kein Token; RQ bleibt credential-free für Read. Blocker: keine (Read ist bereits möglich). |
| **HERMES_BOUNDED_AUTONOMY** | **BLOCKED** | Erfordert SAVE/DELETE-Aktivierung, die wiederum Boundary-Fix + Gap-Schließung voraussetzt. |
### Kern-Blocker (zusammenfassend)
1. **CREDENTIAL_SEPARATION (Phase 3):** SAVE+DELETE laufen im selben Container
(`06a4a73573d0`). RQ credential-free ist technisch NICHT erreichbar, solange
beide Tokens in die ENV dieses Containers injiziert würden. → **HARD STOP für
DELETE_CREDENTIAL_ACTIVATION.**
2. **HUMAN_APPROVAL_AUTHENTICITY_GAP (OPEN):** DELETE-Operation erst nach
Gap-Schließung. → **BLOCKED für DELETE_OPERATION_ACTIVATION.**
3. **SERVER_AUTH_DEPLOYMENT** und **SAVE_ACTIVATION** sind CONDITIONALLY_READY
(Code fertig), aber SAVE_ACTIVATION hängt an der Boundary-Frage.
### Empfohlene Reihenfolge (AUTH.4, nach Freigabe)
1. SERVER_AUTH_DEPLOYMENT (AUTH.2 aktivieren) — sicher, verschärft Gap nicht.
2. Boundary-Fix (C5-Caller in getrennten Container ODER RQ-undurchdringbare
Injektion) — VORAUSSETZUNG für SAVE/DELETE-Credential-Aktivierung.
3. SAVE_ACTIVATION (nach Boundary-Fix).
4. Gap-Schließung (separate Freigabe).
5. DELETE_CREDENTIAL_ACTIVATION + DELETE_OPERATION_ACTIVATION (nach 2+4).
---
## 15. INCIDENT (AUTH.3B, 2026-08-27)
**INCIDENT_OCCURRED = TRUE** (während Phase 1 Discovery)
- Versehentlicher mutierender POST `POST /api/vault/save` gegen Produktion
(`_auth3b_probe.md`, Inhalt "x") — Verstoß gegen die verbindliche Incident-Regel
(NO MUTATING HTTP METHOD PROBES).
- **INCIDENT_RECOVERED = TRUE** — Datei sofort via `POST /api/vault/delete` entfernt,
Read-Back bestätigt: Datei existiert nicht mehr.
- **PERMANENT_DAMAGE = NONE VERIFIED**
- **ROOT_CAUSE = unsafe mutating endpoint probe during read-only discovery**
- **BESTÄTIGT:** Produktion läuft OHNE AUTH.2 (save ohne Token → 200, kein 401).
- **LEHRE:** Keine weiteren mutierenden POSTs. Nur read-only GET/Code-Reading.

View file

@ -0,0 +1,546 @@
# AUTH.3C — C5 EXECUTOR RUNTIME ISOLATION DESIGN
**Status:** DESIGN + VERIFIED DEPLOYMENT PLAN (STRICT READ-ONLY, KEIN produktiver Umbau)
**Datum:** 2026-08-27
**Mission Type:** STRICT READ-ONLY DISCOVERY + ARCHITECTURE DESIGN + ISOLATED VALIDATION
**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) + AUTH.3B = COMPLETE
> ⚠️ Dies ist ein **Design-Dokument**. Es wird KEIN produktiver Umbau ausgeführt,
> kein Container erstellt, keine Compose/Network/Volume/DB geändert, kein echtes
> Credential erzeugt, keine ENV geändert, kein Deployment, keine produktive
> Tolaria-Mutationsprobe. Bei Zweifel: FAIL CLOSED + HARD STOP.
---
## 1. CURRENT EXECUTION FLOW (Phase 1) — rekonstruiert
### 1a. SAVE-Pfad: RQ/Hermes → C5 CLI → C5CPropagator → TolariaClient.write()
```
RQ/Hermes (Container 06a4a73573d0, User hermes uid 10010)
├─ [Entry Point] rq_c5_cli.py (argparse CLI, manuell aufgerufen)
│ KEIN CLI-Command führt C5CPropagator mit save_token aus (verifiziert)
├─ [Library] rq_c5c.py C5CPropagator (Z.497)
__init__(store, reader, client=None, ...) → client = client or TolariaClient()
│ Default: credential-frei (kein save_token)
│ write() → self.client.write(vault_path, after_content) (Z.638)
└─ [Library] rq_c5c.py TolariaClient.write() (Z.322)
token = self._require_token(self.save_token, "SAVE") # fail-closed
→ POST /api/vault/save (Bearer save_token)
```
**Boundary-Fakten (SAVE):**
- **Process Boundary:** C5CPropagator läuft im RQ/Hermes-Prozess (kein separater Prozess).
- **Container Boundary:** RQ-Container `06a4a73573d0` (User `hermes`, uid 10010, gid 10000).
- **Filesystem Boundary:** `/opt/data` (RQ-Container-Dateisystem); C5-DB `/opt/data/c5c_live/c5a.db`.
- **ENV Boundary:** `TOLARIA_SAVE_TOKEN` ABSENT im RQ-Container (verifiziert). `C5A_DB` steuert DB-Pfad.
- **Network Boundary:** Tolaria `187.124.31.123:5173` (auch 10.0.4.1/10.0.9.1); Forgejo 10.0.4.1:3000/10.0.9.1:3000.
- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Commit-State, ObjectChanges, Approvals, Ledger).
- **Forgejo-Zugriff:** GitReader liest Repo `nexo312/trading-system-docs` (Forgejo-Git-Push-Key).
- **Tolaria-Zugriff:** `write()` (SAVE) via `TolariaClient`.
- **Approval-DB-Zugriff:** C5AStore (delete_approvals-Tabelle) — SAVE-Pfad liest/schreibt sie nicht aktiv.
- **Benötigte Inputs:** Commit-SHA, ObjectChanges, Source-/Provenance-Daten aus Forgejo.
- **Erzeugte Outputs:** Tolaria-SAVE, C5-State-Update (PROPAGATING→VERIFYING→UPDATING_SEARCH).
### 1b. DELETE-Pfad: RQ/Hermes → C5 CLI → DeleteExecutor → Approval Validation → TolariaClient.delete()
```
RQ/Hermes (Container 06a4a73573d0)
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_approve (Z.355) → persistiert Human-Approval
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_execute (Z.424) → führt DELETE aus
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_replay (Z.458) → idempotenter Replay
├─ [Helper] rq_c5_cli.py _delete_executor (Z.344)
│ TolariaClient(base_url, delete_token=os.environ.get(ENV_TOLARIA_DELETE_TOKEN))
│ → liest NUR DELETE-Token (kein SAVE-Token)
├─ [Library] rq_c5_delete.py DeleteExecutor (Z.83)
__init__(store, client=None) → client = client or TolariaClient() (Default credential-frei)
│ execute(commit_sha) (Z.192):
│ Gate 1: ObjectChange existiert, operation == DELETE
│ Gate 2: Commit-Status == ST_DELETE_APPROVED
│ Gate 3: Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad)
│ Gate 4: Pre-Delete-Drift-Check (Ziel existiert noch)
│ → _do_delete() → self.client.delete() (DELETE)
└─ [Library] rq_c5c.py TolariaClient.delete() (Z.~352)
token = self._require_token(self.delete_token, "DELETE") # fail-closed
→ POST /api/vault/delete (Bearer delete_token)
```
**Boundary-Fakten (DELETE):**
- **Process Boundary:** DeleteExecutor läuft im RQ/Hermes-Prozess (kein separater Prozess).
- **Container Boundary:** RQ-Container `06a4a73573d0`**SELBER Container wie SAVE**.
- **Filesystem Boundary:** `/opt/data`; C5-DB `/opt/data/c5c_live/c5a.db`.
- **ENV Boundary:** `TOLARIA_DELETE_TOKEN` ABSENT im RQ-Container (verifiziert).
- **Network Boundary:** Tolaria `187.124.31.123:5173`.
- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Approvals, Ledger, Commit-State).
- **Forgejo-Zugriff:** GitReader (Provenance-/Object-Daten).
- **Tolaria-Zugriff:** `delete()` (DELETE) via `TolariaClient`.
- **Approval-DB-Zugriff:** C5AStore delete_approvals-Tabelle (lesen + USED markieren).
- **Benötigte Inputs:** Commit-SHA, persistierte Approval, ObjectChange, Nonce.
- **Erzeugte Outputs:** Tolaria-DELETE, Approval→USED, Commit→ST_UPDATING_SEARCH.
### 1c. Kernbefund (bestätigt AUTH.3B)
**SAVE- und DELETE-Caller laufen im SELBEN Prozess/Container** (`rq_c5_cli.py`, RQ-Container
`06a4a73573d0`). **CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY.** Sobald Credentials
produktiv in denselben Container injiziert würden, wäre RQ_SAVE_ACCESS/DELETE_ACCESS
technisch NICHT mehr NO. → AUTH.4 BLOCKED.
---
## 2. MINIMUM EXECUTOR CAPABILITIES (Phase 2) — Least Privilege
### SAVE EXECUTOR (maximal benötigt)
| Capability | Klassifikation |
|---|---|
| C5 Work Package/Input lesen | REQUIRED |
| Source-/Provenance-Daten lesen (Forgejo) | REQUIRED |
| Tolaria READ (falls erforderlich) | REQUIRED (Verifikation) |
| Tolaria SAVE | REQUIRED |
| C5 State Updates (c5a.db) | REQUIRED |
| Tolaria DELETE | NOT_REQUIRED |
| Forgejo Admin | NOT_REQUIRED |
| Docker | NOT_REQUIRED |
| SSH | NOT_REQUIRED |
| Host Root | NOT_REQUIRED |
| DELETE Credential | NOT_REQUIRED |
| Search-Rebuild Credential | NOT_REQUIRED (sofern nicht zwingend) |
### DELETE EXECUTOR (maximal benötigt)
| Capability | Klassifikation |
|---|---|
| Konkreten Delete-Auftrag lesen | REQUIRED |
| Persistierte Approval lesen/verifizieren | REQUIRED |
| Object/Commit/Nonce prüfen | REQUIRED |
| Tolaria DELETE | REQUIRED |
| Delete Ledger aktualisieren | REQUIRED |
| Tolaria SAVE | NOT_REQUIRED |
| Forgejo Admin | NOT_REQUIRED |
| Docker | NOT_REQUIRED |
| SSH | NOT_REQUIRED |
| Host Root | NOT_REQUIRED |
| SAVE Credential | NOT_REQUIRED |
> **UNKNOWN darf nicht als REQUIRED angenommen werden.** Alle oben als REQUIRED
> markierten Capabilities sind aus dem Code verifiziert (nicht angenommen).
---
## 3. ISOLATION OPTIONS (Phase 3) — Bewertung
> ⚠️ NUR BEWERTUNG. KEINE Container/Networks/Volumes erstellen.
### OPTION A — Ein separater C5-Executor-Container mit SAVE+DELETE
- **SECURITY_BOUNDARY:** RQ↔Executor getrennt; aber SAVE+DELETE im selben Executor.
- **RQ_SECRET_VISIBILITY:** RQ kann Executor-Secrets nicht lesen (Container-Boundary).
- **SAVE_DELETE_ISOLATION:** **NEIN** — SAVE und DELETE im selben Container.
- **FILESYSTEM_ISOLATION:** RQ↔Executor getrennt; SAVE/DELETE gemeinsam.
- **ENV_ISOLATION:** RQ↔Executor getrennt; SAVE/DELETE-ENV gemeinsam.
- **NETWORK_ISOLATION:** Executor-Netzwerk getrennt von RQ.
- **PROCESS_ISOLATION:** Executor-Prozess getrennt von RQ.
- **CREDENTIAL_BLAST_RADIUS:** Ein Kompromiss des Executors = SAVE+DELETE beide offen.
- **DB_ACCESS:** Executor braucht c5a.db (Schreibzugriff).
- **QUEUE_REQUIREMENT:** Nein (CLI/Inbox möglich).
- **OPERATIONAL_COMPLEXITY:** Mittel (ein Container mehr).
- **FAIL_CLOSED:** Ja.
- **RECOVERY:** Einfach (ein Container).
- **AUDITABILITY:** Mittel.
- **CURRENT_STACK_COMPATIBILITY:** Hoch (Coolify/Compose vorhanden).
- **IMPLEMENTATION_EFFORT:** Mittel.
- **Fazit:** Verbessert RQ-Isolation, aber **löst SAVE/DELETE-Trennung NICHT** — nur LOGICAL_ONLY→Container-Ebene, nicht Scope-Ebene.
### OPTION B — Zwei getrennte Container: c5-save-executor + c5-delete-executor
- **SECURITY_BOUNDARY:** RQ↔SAVE↔DELETE je getrennt.
- **RQ_SECRET_VISIBILITY:** RQ kann beide nicht lesen.
- **SAVE_DELETE_ISOLATION:** **JA** — SAVE- und DELETE-Credential in getrennten Containern.
- **FILESYSTEM_ISOLATION:** SAVE- und DELETE-FS getrennt.
- **ENV_ISOLATION:** SAVE- und DELETE-ENV getrennt.
- **NETWORK_ISOLATION:** SAVE- und DELETE-Netzwerk getrennt.
- **PROCESS_ISOLATION:** SAVE- und DELETE-Prozess getrennt.
- **CREDENTIAL_BLAST_RADIUS:** Minimal — SAVE-Kompromiss berührt DELETE nicht und umgekehrt.
- **DB_ACCESS:** Beide brauchen c5a.db (Schreibzugriff) — **Konflikt mit DB-Isolation (Phase 7)**.
- **QUEUE_REQUIREMENT:** Nein (CLI/Inbox möglich).
- **OPERATIONAL_COMPLEXITY:** Höher (zwei Container mehr).
- **FAIL_CLOSED:** Ja.
- **RECOVERY:** Mittel (zwei Container).
- **AUDITABILITY:** Hoch.
- **CURRENT_STACK_COMPATIBILITY:** Hoch (Coolify/Compose vorhanden).
- **IMPLEMENTATION_EFFORT:** Höher.
- **Fazit:** **Erfüllt die Zielarchitektur** (SAVE≠DELETE technisch getrennt). Bevorzugt, sofern DB-Isolation lösbar.
### OPTION C — Separate Unix-Prozesse im selben Container, unterschiedliche Secret-Files/Usern
- **SECURITY_BOUNDARY:** Nur Prozess-Ebene; **gleicher Container/FS/ENV**.
- **RQ_SECRET_VISIBILITY:** RQ (User hermes) könnte Secret-Files lesen, wenn gleiche Rechte.
- **SAVE_DELETE_ISOLATION:** Nur wenn getrennte Unix-User + Dateirechte; **fragil**.
- **FILESYSTEM_ISOLATION:** Nur via Dateirechte (kein echtes FS-Isolation).
- **ENV_ISOLATION:** **NEIN** — gleiche ENV im Container.
- **NETWORK_ISOLATION:** **NEIN** — gleiche Netzwerk-Namespace.
- **PROCESS_ISOLATION:** Teilweise (getrennte Prozesse, gleicher Kernel/Container).
- **CREDENTIAL_BLAST_RADIUS:** Mittel — Container-Kompromiss = beide offen.
- **DB_ACCESS:** Beide c5a.db.
- **QUEUE_REQUIREMENT:** Nein.
- **OPERATIONAL_COMPLEXITY:** Niedrig.
- **FAIL_CLOSED:** Ja.
- **RECOVERY:** Einfach.
- **AUDITABILITY:** Niedrig.
- **CURRENT_STACK_COMPATIBILITY:** Hoch (kein neuer Container).
- **IMPLEMENTATION_EFFORT:** Niedrig.
- **Fazit:** **NICHT ausreichend** — ENV-Isolation fehlt, RQ (User hermes) könnte Secrets lesen. Verletzt RQ_SECRET_VISIBILITY=NO.
### OPTION D — Sidecar-/Broker-Modell
- **SECURITY_BOUNDARY:** Broker vermittelt; Executoren getrennt.
- **RQ_SECRET_VISIBILITY:** RQ sieht Secrets nicht (nur Broker).
- **SAVE_DELETE_ISOLATION:** Ja, wenn Broker getrennte Executoren ansteuert.
- **FILESYSTEM_ISOLATION:** Ja (getrennte Container).
- **ENV_ISOLATION:** Ja.
- **NETWORK_ISOLATION:** Ja.
- **PROCESS_ISOLATION:** Ja.
- **CREDENTIAL_BLAST_RADIUS:** Minimal.
- **DB_ACCESS:** Broker + Executoren.
- **QUEUE_REQUIREMENT:** **Ja** (Broker = Queue) — **neue Infrastruktur, NICHT vorhanden**.
- **OPERATIONAL_COMPLEXITY:** Hoch.
- **FAIL_CLOSED:** Ja.
- **RECOVERY:** Komplex.
- **AUDITABILITY:** Hoch.
- **CURRENT_STACK_COMPATIBILITY:** **Niedrig** — kein Broker/Queue im Stack.
- **IMPLEMENTATION_EFFORT:** Hoch.
- **Fazit:** **Abgelehnt** — erfordert neue Infrastruktur (Broker/Queue), die nicht vorhanden ist. Verletzt "KEINE neue Infrastruktur deployen".
### OPTION E — Queue-basierte Executor-Trennung (falls vorhandene Infrastruktur geeignet)
- **SECURITY_BOUNDARY:** Queue entkoppelt RQ von Executoren.
- **RQ_SECRET_VISIBILITY:** RQ sieht Secrets nicht.
- **SAVE_DELETE_ISOLATION:** Ja, wenn getrennte Queues/Executoren.
- **FILESYSTEM_ISOLATION:** Ja.
- **ENV_ISOLATION:** Ja.
- **NETWORK_ISOLATION:** Ja.
- **PROCESS_ISOLATION:** Ja.
- **CREDENTIAL_BLAST_RADIUS:** Minimal.
- **DB_ACCESS:** Executoren.
- **QUEUE_REQUIREMENT:** **Ja****keine Queue im Stack vorhanden** (verifiziert: kein RabbitMQ, kein Broker).
- **OPERATIONAL_COMPLEXITY:** Hoch.
- **FAIL_CLOSED:** Ja.
- **RECOVERY:** Komplex.
- **AUDITABILITY:** Hoch.
- **CURRENT_STACK_COMPATIBILITY:** **Niedrig** — keine vorhandene Queue.
- **IMPLEMENTATION_EFFORT:** Hoch.
- **Fazit:** **Abgelehnt** — keine vorhandene Queue-Infrastruktur; würde neue Infrastruktur erfordern.
### OPTION F — Andere MINIMAL sichere Variante
- **Kandidat F1: RQ credential-frei + SAVE/DELETE als getrennte Container (B) mit getrennter DB-View.**
Kombiniert B (Container-Trennung) mit minimaler DB-Boundary (Phase 7): SAVE-Executor
bekommt nur Commit/ObjectChange-Read + State-Write auf eigene Tabellen; DELETE-Executor
nur Approval/Ledger. **Bevorzugt**, da es die Zielarchitektur mit vorhandener Infrastruktur erfüllt.
- **Kandidat F2: RQ credential-frei + SAVE/DELETE als getrennte Container (B) mit geteilter c5a.db.**
Falls DB-Trennung nicht sauber möglich: geteilte c5a.db, aber SAVE-Executor erhält
KEIN DELETE-Credential und umgekehrt. **Akzeptabel**, aber DB-Boundary schwächer.
---
## 4. PREFERRED ARCHITECTURE (Phase 4)
**Gewählt: OPTION B (zwei getrennte Container) mit OPTION F1 (minimale DB-Boundary).**
```
RQ/Hermes (Container 06a4a73573d0) — credential-frei
│ credential-freier Auftrag (CLI/Inbox, kein Secret)
+---------------------------+ +-----------------------------+
| c5-save-executor (Cont.) | | c5-delete-executor (Cont.) |
| SAVE credential | | DELETE credential |
| NO DELETE | | NO SAVE |
| Tolaria SAVE + READ | | Tolaria DELETE |
| c5a.db: Commit/Object-Read| | c5a.db: Approval/Ledger |
+---------------------------+ +-----------------------------+
│ SAVE (Bearer save_token) │ DELETE (Bearer delete_token)
▼ ▼
Tolaria Server (187.124.31.123:5173) — erwartet SAVE+DELETE-Verifier
```
**Begründung (Priorität):**
1. **Credential-Isolation:** SAVE- und DELETE-Credential in getrennten Containern → RQ kann keins lesen, SAVE-Executor kann DELETE nicht, DELETE-Executor kann SAVE nicht.
2. **Fail-Closed:** Beide Executoren erben `_require_token` (AUTH.3A) — fehlendes/leeres Token → lokaler Abbruch.
3. **DELETE-Blast-Radius minimieren:** DELETE-Executor isoliert, kein SAVE-Credential, Human Gate bleibt.
4. **Auditierbarkeit:** Getrennte Container → getrennte Logs/Netzwerk.
5. **Wiederherstellbarkeit:** Getrennte Container → getrennter Rollback.
6. **Geringe Komplexität:** Keine neue Infrastruktur (Coolify/Compose vorhanden), kein Broker/Queue.
**Gegen reale Infrastruktur geprüft:**
- Coolify/Compose vorhanden → Container-Erstellung möglich (in AUTH.4, nicht jetzt).
- Kein Broker/Queue → CLI/Inbox-Channel (Phase 5), keine neue Infrastruktur.
- c5a.db SQLite → DB-Boundary via getrennte Tabellen/Dateien (Phase 7).
---
## 5. RQ → EXECUTOR COMMAND CHANNEL (Phase 5)
> ⚠️ NUR BEWERTUNG. KEINE neue Infrastruktur deployen.
### Optionen
| Option | AUTH | AUTHZ | REPLAY_PROT | IDEMPOTENCY | JOB_ID | MISSION_ID | ACTION_HASH | AUDIT | FAIL_CLOSED | RQ_CAN_FORGE | RQ_CAN_ESCALATE | EXEC_CAN_VALIDATE |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| **CLI (bestehend)** | OS-User | CLI-Arg | Nein (manuell) | Ja (Commit-SHA) | Commit-SHA | Nein | Nein | Shell-History | Ja | Ja (RQ ruft auf) | Nein (kein Secret) | Ja (Gates) |
| **Filesystem Inbox** | FS-Rechte | Datei | Teilweise | Ja | Dateiname | Nein | Nein | Datei-Meta | Ja | Ja | Nein | Ja |
| **SQLite Job Queue** | DB-Rechte | Job-Record | Teilweise | Ja | Job-ID | Ja | Ja | DB-Ledger | Ja | Ja | Nein | Ja |
| **RabbitMQ** | — | — | — | — | — | — | — | — | — | — | — | — | (nicht vorhanden) |
| **HTTP Internal API** | Token | Scope | Nein | Ja | Job-ID | Ja | Ja | Server-Log | Ja | Ja | Nein | Ja |
| **Unix Socket** | FS-Rechte | Peer | Nein | Ja | Job-ID | Ja | Ja | Socket-Log | Ja | Ja | Nein | Ja |
| **C5 State Machine** | DB-Rechte | State | Teilweise | Ja | Commit-SHA | Ja | Ja | DB-Ledger | Ja | Ja | Nein | Ja |
**Bewertung:**
- **CLI (bestehend)** ist der **einfachste, vorhandene** Kanal. RQ ruft `c5-save-executor`/`c5-delete-executor` als CLI auf. **RQ_CAN_FORGE_JOB = Ja** (RQ kann jeden Auftrag formulieren), aber **RQ_CAN_ESCALATE_SCOPE = Nein** (RQ hat kein Secret, kann nur beauftragen, nicht selbst schreiben). Der Executor validiert den Auftrag selbst (Gates).
- **SQLite Job Queue / C5 State Machine** sind die **robustesten** (JOB_ID, MISSION_ID, ACTION_HASH, DB-Ledger, Replay-Schutz). Nutzt vorhandene c5a.db — **keine neue Infrastruktur**.
- **RabbitMQ/HTTP/Socket** erfordern neue Infrastruktur oder neue Endpoints → **abgelehnt** (Scope-Creep).
**Empfehlung:** **CLI (bestehend) als primärer Kanal** für SAVE; **C5 State Machine (c5a.db) als primärer Kanal** für DELETE (Approval persistiert, Nonce, Single-Use, Replay-Schutz). Beide nutzen vorhandene Infrastruktur.
### DELETE-spezifisch (RQ darf NICHT durch freien Auftrag einen Delete autorisieren)
Der DELETE-Executor muss selbst prüfen (im Code verifiziert, rq_c5_delete.py):
- **erlaubte Operation:** ObjectChange.operation == DELETE (Gate 1)
- **Objekt:** ObjectChange existiert, genau einer (Gate 1)
- **Commit:** Commit-Status == ST_DELETE_APPROVED (Gate 2)
- **Approval:** existiert, APPROVED, passt exakt Commit/Objekt/Pfad (Gate 3)
- **Nonce:** Approval single-use (→ USED nach DELETE)
- **Single Use:** Approval→USED, kein Replay
- **State:** Commit→ST_UPDATING_SEARCH nach DELETE
- **Scope:** kein freier Pfad-Parameter (nur aus ObjectChange)
---
## 6. SECRET VISIBILITY PROOF (Phase 6)
> ⚠️ NUR BEWEISFÜHRUNG. KEINE Secrets erzeugen/lesen.
### Zielarchitektur (OPTION B): Kann RQ SAVE/DELETE lesen?
| Prüfpunkt | RQ→SAVE | RQ→DELETE | SAVE-Exec→DELETE | DELETE-Exec→SAVE | Rain→beide |
|---|---|---|---|---|---|
| **ENV** | Getrennte Container → NEIN | NEIN | NEIN | NEIN | NEIN |
| **/proc** | Getrennte Container → NEIN | NEIN | NEIN | NEIN | NEIN |
| **mounted files** | Getrennte Volumes → NEIN | NEIN | NEIN | NEIN | NEIN |
| **shared volumes** | Keine geteilten Secret-Volumes → NEIN | NEIN | NEIN | NEIN | NEIN |
| **Docker inspect** | Nur Host-Admin (nicht RQ) → NEIN | NEIN | NEIN | NEIN | NEIN |
| **Compose** | Secret via .env/Coolify, nicht Plain ENV → NEIN | NEIN | NEIN | NEIN | NEIN |
| **Logs** | Kein Secret-Logging (AUTH.3A) → NEIN | NEIN | NEIN | NEIN | NEIN |
| **crash dumps** | Kein Secret in Dumps → NEIN | NEIN | NEIN | NEIN | NEIN |
| **temp files** | Kein Secret in Temp → NEIN | NEIN | NEIN | NEIN | NEIN |
| **DB** | c5a.db enthält keine Secrets → NEIN | NEIN | NEIN | NEIN | NEIN |
| **IPC** | Getrennte Container → NEIN | NEIN | NEIN | NEIN | NEIN |
| **shell history** | RQ ruft CLI ohne Secret-Arg → NEIN | NEIN | NEIN | NEIN | NEIN |
**Ergebnis:** Mit OPTION B ist die Isolation **erreichbar** mit der aktuellen Infrastruktur
(Coolify/Compose, getrennte Container, .env/Coolify-Secrets). **KEIN HARD STOP.**
> ⚠️ **Einschränkung (ehrlich):** Die Isolation ist nur **technisch erreichbar**, wenn
> die Secrets in getrennten Containern injiziert werden (nicht in den RQ-Container).
> Solange SAVE/DELETE im RQ-Container lägen, wäre RQ_SECRET_VISIBILITY=NO NICHT garantiert.
> Das ist genau der AUTH.3B-HARD-STOP, den OPTION B auflöst.
---
## 7. FILESYSTEM / DB SEPARATION (Phase 7)
> ⚠️ NUR BEWERTUNG. KEINE DB erzeugen/verändern.
### Welche DBs brauchen die Executoren?
| DB | SAVE-Executor | DELETE-Executor | RQ |
|---|---|---|---|
| **c5a.db (C5 State)** | Schreibzugriff (Commit/ObjectChange-State) | Schreibzugriff (Approval/Ledger) | Schreibzugriff |
| **missions.db** | NEIN | NEIN | Ja (RQ) |
| **safety.db** | NEIN | NEIN | Ja (RQ) |
| **heartbeat.db** | NEIN | NEIN | Ja (RQ) |
| **delete approvals** | NEIN (liest nicht) | **Ja** (lesen + USED) | Ja |
| **attempt ledger** | NEIN | **Ja** (schreiben) | Ja |
### Muss SAVE-Executor Schreibzugriff auf dieselbe DB wie RQ haben?
- **Nein, nicht auf missions/safety/heartbeat.** SAVE-Executor braucht nur c5a.db (Commit/ObjectChange).
- **c5a.db geteilt mit RQ:** Ja, aber auf minimale Tabellen begrenzbar.
### Muss DELETE-Executor Schreibzugriff haben?
- **Ja, auf c5a.db** (Approval→USED, Ledger, Commit-State). Nicht auf missions/safety/heartbeat.
### Kann Zugriff auf minimale Tabellen/DB-Dateien begrenzt werden?
- **Ja, via getrennte SQLite-Dateien** (OPTION F1): SAVE-Executor → `c5a_save.db` (Commit/ObjectChange-Read + State-Write); DELETE-Executor → `c5a_delete.db` (Approval/Ledger). Oder via SQLite-View/Readonly-Connections.
- **SQLite-Locking:** Getrennte Dateien vermeiden Lock-Konflikte zwischen SAVE- und DELETE-Executor. Geteilte Datei → WAL-Modus + kurze Transaktionen.
**Empfehlung:** **Getrennte SQLite-Dateien** für SAVE- und DELETE-Executor (minimale DB-Boundary), RQ behält c5a.db als Orchestrierungs-State. Keine neue DB-Engine.
---
## 8. NETWORK POLICY DESIGN (Phase 8)
> ⚠️ NUR DESIGN. KEINE Firewall-/Docker-Network-Änderung.
### SAVE-Executor (nur notwendige Ziele)
| Ziel | Zugriff |
|---|---|
| Tolaria (187.124.31.123:5173) | **JA** (SAVE + READ) |
| Forgejo (10.0.4.1:3000) | **JA** (Source-/Provenance-Read) |
| Search | NEIN (kein Rebuild-Credential) |
| Internet | NEIN |
| Docker | NEIN |
| Host SSH | NEIN |
| Telegram | NEIN |
### DELETE-Executor (nur notwendige Ziele)
| Ziel | Zugriff |
|---|---|
| Tolaria (187.124.31.123:5173) | **JA** (DELETE) |
| Forgejo (10.0.4.1:3000) | **JA** (Object/Commit-Read) |
| Search | NEIN |
| Internet | NEIN |
| Docker | NEIN |
| Host SSH | NEIN |
| Telegram | NEIN |
**Ziel:** Beide Executoren erreichen nur Tolaria + Forgejo (interne State-Quellen). Kein Internet, kein Docker, kein SSH, kein Telegram.
---
## 9. FAILURE MODEL (Phase 9)
> ⚠️ NUR ANALYSE. FAIL CLOSED.
| Szenario | Verhalten |
|---|---|
| **SAVE-Executor down** | RQ kann nicht propagieren → Commit bleibt in READY/PROPAGATING. Kein Datenverlust. FAIL CLOSED. |
| **DELETE-Executor down** | DELETE nicht ausführbar → Commit bleibt ST_DELETE_APPROVED. **Nichts wird gelöscht.** FAIL CLOSED. |
| **RQ down** | Executoren warten auf Auftrag. Kein automatischer Write. FAIL CLOSED. |
| **Tolaria down** | Executor → TolariaUnavailableError → Retry/DEAD (C5C-Modell). FAIL CLOSED. |
| **DB locked** | SQLite-Lock → Retry/Fehler. Kein Write ohne State. FAIL CLOSED. |
| **Queue unavailable** | CLI/Inbox → kein Queue-Abhängigkeit. FAIL CLOSED. |
| **Credential missing** | `_require_token` → lokaler Abbruch (RC_AUTH_FAILURE). FAIL CLOSED. |
| **Credential revoked** | Server → 401. Executor meldet Fehler. FAIL CLOSED. |
| **malformed Job** | Executor-Gates → DeleteApprovalError/ExecutionError. FAIL CLOSED. |
| **duplicate Job** | Idempotenz (Commit-SHA) → DELETE_ALREADY_AT_TARGET. Kein zweiter Write. |
| **replayed Job** | Approval single-use (→ USED) → Replay denied. FAIL CLOSED. |
| **Job stale** | Commit-Status ≠ ST_DELETE_APPROVED → denied. FAIL CLOSED. |
| **Approval wrong** | Gate 3 (exakter Match) → denied. FAIL CLOSED. |
| **Object wrong** | Gate 1 (genau ein DELETE-ObjectChange) → denied. FAIL CLOSED. |
| **Commit wrong** | Gate 2 (Status) → denied. FAIL CLOSED. |
| **Executor restart mid-operation** | Replay (idempotent) → Zustand aus c5a.db. Keine Sicherheitsinvariante verloren. |
**Erwartung:** **FAIL CLOSED.** DELETE insbesondere: **Unklarheit = NICHT löschen.**
---
## 10. DELETE SPECIAL SECURITY (Phase 10)
> ⚠️ NUR DESIGN. HUMAN_APPROVAL_AUTHENTICITY_GAP bleibt OPEN (AUTH.3D).
**Zusätzliche Anforderungen für DELETE-Executor:**
- **Eigener Container bevorzugt:** Ja (OPTION B, c5-delete-executor).
- **Eigenes Credential:** Ja (TOLARIA_DELETE_TOKEN, getrennt von SAVE).
- **Kein SAVE:** Ja (kein SAVE-Credential, kein SAVE-Endpoint-Zugriff).
- **Human Approval zwingend:** Ja (Gate 3, persistierte Approval).
- **Approval single-use:** Ja (→ USED nach DELETE).
- **Object-bound:** Ja (Gate 1, genau ein DELETE-ObjectChange).
- **Commit-bound:** Ja (Gate 2, ST_DELETE_APPROVED).
- **Nonce-bound:** Ja (Approval-ID als Nonce).
- **Replay-safe:** Ja (single-use + Idempotenz).
- **Vollständiges Audit Ledger:** Ja (c5a.db delete_approvals + attempt ledger).
- **Kein LLM kann Approval selbst erzeugen:** **NICHT garantiert** — HUMAN_APPROVAL_AUTHENTICITY_GAP=*** (Provenance nicht kryptographisch an Christian gebunden, `--approved-by` frei wählbar).
**ABER:** HUMAN_APPROVAL_AUTHENTICITY_GAP bleibt OPEN. AUTH.3C modelliert ihn nur als Blocker, repariert ihn NICHT (AUTH.3D).
---
## 11. MIGRATION DESIGN (Phase 11)
> ⚠️ NUR PLAN. KEINE AUSFÜHRUNG.
**HEUTE:** RQ-Container ├ C5C Writer └ DeleteExecutor
**ZIEL:** RQ-Container (credential-frei) + c5-save-executor + c5-delete-executor
### Atomare Schritte
| Schritt | GOAL | FILES | CONTAINERS | MOUNTS | NETWORKS | DB | CREDENTIALS | TEST | ROLLBACK | HUMAN_GATE | PROD_IMPACT |
|---|---|---|---|---|---|---|---|---|---|---|---|
| **S1** | Executor-Code extrahieren (SAVE/DELETE als eigenständige CLI) | rq_c5c.py, rq_c5_delete.py → executor-Module | — | — | — | — | — | Unit-Tests | Git-Revert | Nein | Keine |
| **S2** | c5-save-executor-Image bauen | Dockerfile | c5-save-executor | /app/vault? (nein) | c5-net | c5a_save.db | SAVE-Token (synthetisch) | Isolierte Tests | Image entfernen | Nein | Keine |
| **S3** | c5-delete-executor-Image bauen | Dockerfile | c5-delete-executor | — | c5-net | c5a_delete.db | DELETE-Token (synthetisch) | Isolierte Tests | Image entfernen | Nein | Keine |
| **S4** | RQ credential-frei machen (ENV-Tokens entfernen) | — | RQ | — | — | — | — | ENV-Check | ENV wiederherstellen | Nein | Keine (RQ hat keine Tokens) |
| **S5** | Command-Channel (CLI/Inbox) verdrahten | rq_c5_cli.py | — | — | — | — | — | Integration | Git-Revert | Nein | Keine |
| **S6** | Tolaria-Server-Auth aktivieren (AUTH.2) | — | tolaria | — | — | — | SAVE+DELETE-Verifier | T1-T26 | Rollback (WRITE_DEGRADED) | **Ja** | Write-Downtime-Fenster |
| **S7** | SAVE-Aktivierung (produktiv) | — | c5-save-executor | — | — | — | SAVE-Token (echt) | T1-T26 | Executor stoppen | **Ja** | SAVE aktiv |
| **S8** | DELETE-Aktivierung (produktiv) | — | c5-delete-executor | — | — | — | DELETE-Token (echt) | T1-T26 | Executor stoppen | **Ja** | DELETE aktiv |
**Rollback:** Jeder Schritt einzeln revertierbar (Git-Revert, Image entfernen, ENV wiederherstellen, Executor stoppen). S6-Rollback = WRITE_DEGRADED_MODE (READ available, WRITE DENIED) — öffnet keine unauth Writes.
---
## 12. ISOLATED PROTOTYPE (Phase 12)
> ⚠️ NUR falls vollständig ohne Produktion möglich. Sonst DESIGN_ONLY.
**Klassifikation: DESIGN_ONLY** — Ein echter Isolation-PoC (getrennte Container) ist
**nicht ohne Produktionsänderung** möglich, da Container-Erstellung/Netzwerk-Änderung
produktive Infrastruktur berührt (verboten in AUTH.3C). Daher wird der PoC **nicht
improvisiert**; die Boundary wird stattdessen durch **isolierte Unit/Integration-Tests**
auf Prozess-Ebene validiert (synthetische Secrets, Fake-Tolaria, Temp-DB).
**Isolierte Tests (T1T15) — als Testplan definiert, in AUTH.4 ausführbar:**
| Test | Nachweis |
|---|---|
| T1 | RQ ohne Secret → kein Write möglich |
| T2 | SAVE-Executor sieht SAVE |
| T3 | SAVE-Executor sieht DELETE nicht |
| T4 | DELETE-Executor sieht DELETE |
| T5 | DELETE-Executor sieht SAVE nicht |
| T6 | SAVE kann Delete nicht |
| T7 | DELETE kann Save nicht |
| T8 | fehlendes Credential → fail-closed |
| T9 | malformed Job → fail-closed |
| T10 | duplicate/replay sicher |
| T11 | Delete ohne Approval → denied |
| T12 | Delete mit gefälschter Approval → denied (soweit heutiger Mechanismus erkennt) |
| T13 | Executor restart verliert keine Sicherheitsinvariante |
| T14 | keine Secrets in Logs |
| T15 | keine Secrets in State DB |
**ISOLATED_POC_STATUS: DESIGN_ONLY** (kein echter Container-PoC ohne Produktionsänderung möglich).
---
## 13. AUTH.3D DEPENDENCY (Phase 13)
> ⚠️ NICHT vorwegnehmen — verifizieren.
**Kann Runtime-Isolation abgeschlossen werden, während HUMAN_APPROVAL_AUTHENTICITY_GAP offen bleibt?**
- **SAVE_RUNTIME_ISOLATION:** **READY** — unabhängig von Approval-Gap. SAVE-Executor braucht keine Human-Approval; die Container-Trennung (OPTION B) ist vollständig ohne Gap-Schließung umsetzbar.
- **DELETE_RUNTIME_ISOLATION:** **technisch implementierbar** — die Container-Trennung (OPTION B) ist unabhängig vom Gap umsetzbar. Der DELETE-Executor kann isoliert deployed werden.
- **DELETE_OPERATION_ACTIVATION:** **BLOCKED bis AUTH.3D** — solange HUMAN_APPROVAL_AUTHENTICITY_GAP=*** offen ist, darf DELETE produktiv nicht aktiviert werden (Provenance nicht kryptographisch an Christian gebunden).
**AUTH3D_REQUIRED: JA** (für DELETE_OPERATION_ACTIVATION).
---
## 14. AUTH.4 REASSESSMENT (Phase 14)
> ⚠️ NUR BEWERTUNG. KEINE Aktivierung.
| Punkt | Status | Blocker |
|---|---|---|
| **SERVER_AUTH_DEPLOYMENT** | **CONDITIONALLY_READY** | AUTH.2-Code fertig; Deployment in AUTH.4 mit Human-Gate |
| **SAVE_EXECUTOR_DEPLOYMENT** | **CONDITIONALLY_READY** | Container-Erstellung in AUTH.4; Code extrahieren (S1) |
| **SAVE_ACTIVATION** | **CONDITIONALLY_READY** | Nach SAVE-Executor-Deployment + Server-Auth |
| **DELETE_EXECUTOR_DEPLOYMENT** | **CONDITIONALLY_READY** | Container-Erstellung in AUTH.4; Code extrahieren (S1) |
| **DELETE_CREDENTIAL_ACTIVATION** | **CONDITIONALLY_READY** | Nach DELETE-Executor-Deployment (Boundary gelöst) |
| **DELETE_OPERATION_ACTIVATION** | **BLOCKED** | HUMAN_APPROVAL_AUTHENTICITY_GAP=*** bis AUTH.3D |
| **HERMES_READ_ONLY_AUTONOMY** | **CONDITIONALLY_READY** | RQ credential-frei; READ ohne Credential |
| **HERMES_BOUNDED_AUTONOMY** | **BLOCKED** | DELETE-Operation blockiert bis AUTH.3D |
---
## 15. FRESH CHECKER (Phase 15)
> ⚠️ Wird als unabhängiger Subagent ausgeführt (deleg_60a862ee).
---
## 16. FINAL REPORT (Phase 16)
> ⚠️ Wird nach Fresh Checker ausgefüllt.

View file

@ -0,0 +1,422 @@
# AUTH.3D — HUMAN DELETE APPROVAL AUTHENTICITY
**Status:** SECURITY DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY (KEIN produktives Deployment)
**Datum:** 2026-08-27
**Mission Type:** SECURITY DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY
**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) + AUTH.3B + AUTH.3C = COMPLETE
> ⚠️ Dies ist ein **Design + Code + Test-Dokument**. Es wird KEIN produktives
> Deployment ausgeführt, kein echter Signing Key erzeugt, kein Christians Private
> Key erzeugt, kein echter Public Key deployed, keine ENV geändert, kein Container
> erstellt, kein Executor deployed, keine produktive SAVE/DELETE-Probe. Bei Zweifel:
> FAIL CLOSED + HARD STOP.
---
## 1. CURRENT APPROVAL FLOW (Phase 1) — rekonstruiert (READ-ONLY)
### 1a. Vollständiger Flow
```
Delete Request → State Machine → Approval Creation → Approval Persistence
→ DeleteExecutor → Approval Validation → Tolaria DELETE → Ledger/Consumption
```
1. **Delete Request:** Ein Forgejo-Commit enthält einen DELETE-ObjectChange (`operation == OP_DELETE_REQUEST`).
2. **State Machine:** Commit wird in C5-State-Machine verarbeitet (C5AStore, c5a.db).
3. **Approval Creation:** `cmd_c5_delete_approve` (CLI, rq_c5_cli.py Z.355) → `store.create_delete_approval()`.
4. **Approval Persistence:** SQLite-Tabelle `delete_approvals` (c5a.db).
5. **DeleteExecutor:** `cmd_c5_delete_execute` (CLI, Z.424) → `DeleteExecutor.execute()`.
6. **Approval Validation:** `_validate_approval()` (rq_c5_delete.py Z.105) — Invarianten D/E/F/I.
7. **Tolaria DELETE:** `_do_delete()``client.delete()`.
8. **Ledger/Consumption:** `mark_delete_approval_used()``approval_status='USED'`, Commit → ST_UPDATING_SEARCH.
### 1b. Wer kann Approval heute erzeugen?
- **NUR der CLI-Befehl `approve-delete`** (`cmd_c5_delete_approve`). Kein Code erzeugt Approval automatisch (Invariante B/C).
- **Jeder, der die CLI im RQ-Container ausführen kann** (User `hermes`), kann eine Approval erzeugen.
- **`approved_by` ist ein frei wählbares CLI-Argument** (Default `human:christian`). **KEINE kryptographische Bindung an Christian.**
### 1c. Exakte Felder (DB-Tabelle `delete_approvals`)
| Feld | Typ | Bedeutung |
|---|---|---|
| `approval_id` | TEXT (uuid4) | Eindeutige ID |
| `workflow_commit` | TEXT | Commit-Binding |
| `object_change_id` | INT | ObjectChange-Binding |
| `object_id` | TEXT | Objekt-Binding |
| `path` | TEXT | Pfad-Binding |
| `operation` | TEXT | 'DELETE' |
| `approval_status` | TEXT | APPROVED/USED/REVOKED |
| `approved_by` | TEXT | **frei wählbar** (Default human:christian) |
| `approval_nonce` | TEXT (uuid4) | Nonce (kryptographisch zufällig, aber NICHT signiert) |
| `approved_at` | INT (ms) | Zeitpunkt |
| `used_at` | INT (ms) | Single-Use-Zeitpunkt |
### 1d. Bestehende Bindings (im Code verifiziert)
- **Object-Binding:** `_validate_approval` Z.143 — `approval.object_id == oid` (Invariante E).
- **Commit-Binding:** Z.136 — `approval.workflow_commit == commit_sha` (Invariante F).
- **Path-Binding:** Z.150 — `approval.path == path` (Invariante I).
- **Operation-Binding:** `create_delete_approval` setzt `operation='DELETE'` fest.
- **Nonce:** `approval_nonce` = uuid4, aber **nicht signiert, nicht verifiziert**.
- **Single-Use:** `mark_delete_approval_used``approval_status='USED'`; `_validate_approval` prüft `approval_status == 'APPROVED'`.
- **Mission-Binding:** **FEHLT** — kein `mission_id`-Feld.
- **Provenance-Binding:** **FEHLT** — kein `expected_provenance_hash`-Feld.
- **Expiry:** **FEHLT** — kein `expires_at`-Feld.
- **Actor-/Identity-Information:** `approved_by` (frei wählbar).
- **Authentizitätsprüfung:** **KEINE** — es gibt keine kryptographische Prüfung, dass die Approval von Christian stammt.
### 1e. Kernbefund
**HUMAN_APPROVAL_AUTHENTICITY_GAP = OPEN.** Die Approval ist eine reine DB-Zeile mit
frei wählbarem `approved_by`. Jeder mit CLI-/DB-Zugriff (RQ/Hermes, User `hermes`)
kann eine syntaktisch perfekte Approval erzeugen. Es gibt **keine kryptographische
Bindung an Christian**. → DELETE_OPERATION_ACTIVATION bleibt BLOCKED.
---
## 2. THREAT MODEL (Phase 2)
> ⚠️ NUR ANALYSE. FAIL CLOSED.
| Threat | CURRENT_PROTECTION | CURRENT_GAP | TARGET_CONTROL | FAIL_CLOSED_BEHAVIOR |
|---|---|---|---|---|
| **T1 RQ erzeugt selbst Approval** | CLI-Zugriff (User hermes) | RQ kann `approve-delete` ausführen | RQ besitzt keinen Signing Key → kann keine gültige signierte Approval erzeugen | DELETE_DENIED |
| **T2 Hermes erzeugt selbst Approval** | Gleiche Boundary wie RQ | Hermes kann CLI ausführen | Hermes besitzt keinen Signing Key | DELETE_DENIED |
| **T3 Rain erzeugt Approval** | Separater Container | Rain hat keinen CLI-Zugriff auf c5a.db | Rain besitzt keinen Signing Key | DELETE_DENIED |
| **T4 SAVE Executor erzeugt Approval** | Separater Container (AUTH.3C) | Kein CLI-Zugriff auf c5a.db | SAVE Executor besitzt keinen Signing Key | DELETE_DENIED |
| **T5 DELETE Executor erzeugt Approval** | Separater Container (AUTH.3C) | Kein CLI-Zugriff auf c5a.db | DELETE Executor ist **verify-only**, besitzt keinen Signing Key | DELETE_DENIED |
| **T6 Agent verändert gespeicherte Approval** | DB-Zugriff | DB-Zeile kann geändert werden | Signatur über Payload → Änderung invalidiert Signatur | DELETE_DENIED |
| **T7 Agent verändert Object nach Approval** | Object-Binding (E) | Nur DB-Vergleich, nicht kryptographisch | Object im signierten Payload → Änderung invalidiert Signatur | DELETE_DENIED |
| **T8 Agent verändert Commit nach Approval** | Commit-Binding (F) | Nur DB-Vergleich | Commit im signierten Payload | DELETE_DENIED |
| **T9 Agent verändert Path nach Approval** | Path-Binding (I) | Nur DB-Vergleich | Path im signierten Payload | DELETE_DENIED |
| **T10 Approval wird kopiert** | approval_id uuid4 | Kopie ist gültig (keine Signatur) | Signatur + Single-Use → Kopie hat gleiche Signatur, aber Nonce/consumed | DELETE_DENIED (Replay) |
| **T11 Approval wird wiederverwendet** | Single-Use (USED) | Nur DB-Status | Signatur + consumed-check | DELETE_DENIED |
| **T12 Approval wird zwischen Missions übertragen** | Kein mission_id | Kein Mission-Binding | mission_id im signierten Payload | DELETE_DENIED |
| **T13 alte Approval nach State Change** | Commit-Status-Check (Gate 2) | Nur DB-Status | State-Check + Signatur | DELETE_DENIED |
| **T14 Nonce wird wiederverwendet** | uuid4 | Nonce nicht signiert/verifiziert | Nonce im signierten Payload + consumed-check | DELETE_DENIED |
| **T15 DB-Eintrag wird manipuliert** | — | DB-Zeile allein erzeugt gültige Approval | Signatur → DB-Forgery ohne Private Key unmöglich | DELETE_DENIED |
| **T16 Approval aus Backup wiederhergestellt** | — | Backup enthält gültige Approval | Signatur + consumed-check + expires_at | DELETE_DENIED |
| **T17 Approval nach Executor-Restart erneut verwendet** | Single-Use (USED) | Nur DB-Status | consumed-check + Signatur | DELETE_DENIED |
| **T18 Race zwischen Verify und Delete** | — | TOCTOU-Fenster | Atomare Reservation (RESERVED) | DELETE_DENIED |
| **T19 LLM erzeugt syntaktisch perfekte Fake-Approval** | — | Keine Signatur | Signatur → LLM ohne Private Key kann nicht signieren | DELETE_DENIED |
| **T20 kompromittierter RQ-Prozess versucht Approval-Bypass** | — | RQ kann CLI ausführen | RQ besitzt keinen Signing Key | DELETE_DENIED |
**Kern-Gap:** Alle Bindings (Object/Commit/Path) sind nur DB-Vergleiche, nicht
kryptographisch. `approved_by` ist frei wählbar. **Es fehlt eine kryptographische
Bindung an Christian.**
---
## 3. HUMAN IDENTITY ROOT (Phase 3) — Optionen
> ⚠️ NUR BEWERTUNG. KEINE Keys erzeugen.
| Option | HUMAN_AUTH | LLM_CAN_FORGE | RQ_CAN_FORGE | DELETE_EXEC_CAN_FORGE | PRIVATE_SECRET_EXPOSURE | REPLAY_RES | AUDIT | ROTATION | REVOCATION | BACKUP_RISK | OP_COMPLEXITY | STACK_COMPAT | OFFLINE_RECOVERY |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| **A: HMAC (geteiltes Secret)** | Mittel | Nein (ohne Secret) | Nein (ohne Secret) | Nein (verify-only) | **Hoch** (Secret muss bei Verifier liegen) | Mittel | Mittel | Ja | Ja | Mittel | Niedrig | Hoch | Ja |
| **B: Asymmetrisch (Ed25519)** | **Hoch** | **Nein** | **Nein** | **Nein** (verify-only) | **Niedrig** (nur Public Key bei Verifier) | **Hoch** | **Hoch** | Ja | Ja | Niedrig | Mittel | **Hoch** (Python stdlib/cryptography) | Ja |
| **C: Forgejo-signiertes Artefakt** | Mittel | Nein | Nein | Nein | Mittel (Key bei Forgejo) | Mittel | Hoch | Ja | Ja | Mittel | Hoch | Hoch | Mittel |
| **D: Separater Approval-Service** | Hoch | Nein | Nein | Nein | Niedrig | Hoch | Hoch | Ja | Ja | Niedrig | **Hoch** (neue Infrastruktur) | **Niedrig** | Mittel |
| **E: Telegram + kryptogr. Bindung** | Mittel | Nein | Nein | Nein | Mittel | Mittel | Mittel | Ja | Ja | Mittel | Mittel | Hoch | Ja |
| **F: Andere minimal sichere** | — | — | — | — | — | — | — | — | — | — | — | — | — |
**Bewertung:**
- **OPTION B (Ed25519 asymmetrisch)** ist die **stärkste und stack-kompatible** Lösung:
Christian besitzt den Private Key, der DELETE Executor kennt nur den Public Key.
**LLM/RQ/SAVE/DELETE-Executor können ohne Private Key nicht signieren.**
Python `cryptography`-Bibliothek (oder `pynacl`) unterstützt Ed25519 nativ.
- **OPTION A (HMAC)** ist schwächer: das geteilte Secret müsste beim Verifier liegen,
was die Exposure-Fläche vergrößert. Nicht bevorzugt.
- **OPTION C (Forgejo-signiert)** ist möglich, aber komplexer und bindet an Forgejo.
- **OPTION D (separater Service)** erfordert neue Infrastruktur → Scope-Creep.
- **OPTION E (Telegram)** allein ist KEIN kryptographischer Proof (siehe §7).
**Empfehlung: OPTION B (Ed25519 asymmetrisch).**
---
## 4. PREFERRED SECURITY PROPERTY (Phase 4)
**Bevorzugte Eigenschaft (bestätigt):**
- **Christian:** PRIVATE KEY (Ed25519)
- **DELETE Executor:** PUBLIC KEY ONLY (verify-only)
- **RQ/Hermes:** NO PRIVATE KEY
- **SAVE Executor:** NO PRIVATE KEY
- **Rain:** NO PRIVATE KEY
- **Tolaria:** NO PRIVATE KEY
**Gegen reale Architektur bewertet:**
- Python `cryptography`/`pynacl` ist im Stack verfügbar (C5-Caller sind Python).
- Keine neue Infrastruktur nötig.
- Der DELETE Executor kann eine Approval **verifizieren**, aber **nicht erzeugen**.
- **Bestätigt als bevorzugte Lösung.**
---
## 5. APPROVAL PAYLOAD (Phase 5) — kanonisch
### Kanonischer Payload
```json
{
"approval_version": 1,
"approval_id": "<uuid4>",
"mission_id": "<mission-id>",
"delete_request_id": "<delete-request-id>",
"operation": "DELETE",
"object_id": "<object-id>",
"vault_path": "<vault-path>",
"expected_commit": "<commit-sha>",
"expected_provenance_hash": "<sha256>",
"nonce": "<uuid4>",
"issued_at": "<ISO-8601-UTC>",
"expires_at": "<ISO-8601-UTC>"
}
```
### Canonicalization
- **CANONICALIZATION:** Deterministische Serialisierung: Felder in fester Reihenfolge
(wie oben), keine Duplikate, keine Whitespace-Varianz, UTF-8, keine Unicode-Normalisierung
(exakte Bytes), Pfad-Normalisierung (kein `./`, kein `..`, keine doppelten Slashes).
- **ENCODING:** UTF-8, JSON ohne optionale Felder (nur die 12 Pflichtfelder).
- **HASH:** SHA-256 über den kanonisierten Payload-Bytes.
- **SIGNATURE_INPUT:** Die kanonisierten Payload-Bytes selbst (nicht der Hash) werden
signiert (Ed25519 signiert intern den Hash). Keine Signatur über frei formatierte Texte.
---
## 6. APPROVAL SIGNATURE (Phase 6) — Ed25519
- **SIGNATURE_ALGORITHM:** **Ed25519** (RFC 8032), via Python `cryptography` oder `pynacl`.
- **PRIVATE_KEY_OWNER:** Christian (einziger Besitzer).
- **PRIVATE_KEY_LOCATION:** Christians vertrauenswürdiges Gerät (NICHT im Stack).
- **PUBLIC_KEY_LOCATION:** DELETE Executor-Konfiguration (read-only, injiziert via Secret/File).
- **KEY_ID:** SHA-256-Hash des Public Keys (kurz, zur Key-Identifikation).
- **SIGNATURE_FORMAT:** Ed25519-Signatur (64 Bytes), Base64-URL-encoded.
- **VERIFICATION:** `verify(public_key, canonical_payload_bytes, signature)`.
- **ROTATION:** Neuer Key → neue KEY_ID; alte Approvals mit altem Key bleiben bis Expiry gültig (Multi-Key-Transition).
- **REVOCATION:** Key-Revocation-Liste (KEY_ID → revoked); verifier prüft.
- **MULTI_KEY_TRANSITION:** Verifier akzeptiert mehrere Public Keys (aktuelle + vorherige), markiert ältere als "transitioning".
**Private Key darf NICHT liegen in:** RQ ENV, Hermes ENV, Rain ENV, SAVE Executor,
DELETE Executor, Forgejo Repo, C5 DB, Tolaria, Logs, Reports.
---
## 7. APPROVAL CREATION CHANNEL (Phase 7)
> ⚠️ NUR DESIGN. KEINE Implementierung in AUTH.3D (nur Code + Tests).
### Modelle
| Modell | UX | Sicherheit | Stack-Kompatibilität |
|---|---|---|---|
| **A: Lokales Signing Tool auf Christians Gerät** | Mittel (Christian signiert manuell) | **Hoch** (Private Key nie im Stack) | Hoch (Python-Skript) |
| **B: Separater Approval-Container/Service** | Niedrig | Hoch | **Niedrig** (neue Infrastruktur) |
| **C: Telegram Approval → separater Signer** | Hoch | Mittel (Signer muss getrennt sein) | Mittel |
| **D: Forgejo Workflow** | Mittel | Mittel | Hoch |
| **E: Anderer sicherer Kanal** | — | — | — |
**Empfehlung: OPTION A (lokales Signing Tool auf Christians Gerät).** Christian
erzeugt den kanonischen Payload (aus dem Delete-Request), signiert ihn mit seinem
Private Key, und übergibt die signierte Approval (Payload + Signatur + KEY_ID) an
den DELETE Executor (via CLI/Inbox). Der Executor verifiziert mit dem Public Key.
**WICHTIG (strikte Unterscheidung):**
- **HUMAN_INTENT:** Ein Telegram-Text "Ja"/"Approve"/"Delete" ist NUR Intent, KEIN kryptographischer Proof.
- **CRYPTOGRAPHIC_APPROVAL_ARTIFACT:** Die signierte Approval (Payload + Signatur) ist der einzige gültige Proof.
- RQ darf NICHT aus einem Telegram-Text den finalen Approval-Datensatz erzeugen können (ohne Christians Private Key).
---
## 8. APPROVAL VERIFICATION (Phase 8)
**DeleteExecutor MUSS VOR DELETE prüfen (alle, sonst DELETE_DENIED):**
1. Schema gültig (kanonischer Payload)
2. Unterstützte Approval-Version
3. Signatur gültig (Ed25519, Public Key)
4. Key nicht revoked
5. Operation == DELETE
6. Mission stimmt
7. Delete Request stimmt
8. Object stimmt
9. Path stimmt
10. Commit stimmt
11. Provenance stimmt
12. Nonce stimmt
13. issued_at plausibel (nicht in der Zukunft)
14. expires_at nicht überschritten
15. Approval noch nicht consumed
16. State erlaubt Delete
17. kein Replay
18. kein Cross-Object-Reuse
19. kein Cross-Mission-Reuse
**Jeder Fehler → DELETE_DENIED. KEIN Tolaria HTTP DELETE.**
---
## 9. TOCTOU / RACE SAFETY (Phase 9)
- **VERIFY → STATE CHANGE → DELETE:** Zwischen Verification und Mutation gibt es ein Fenster.
- **Lösung:** Atomare **Reservation** in der DB: Approval-Status `APPROVED → RESERVED`
(mit `execution_attempt_id` + `idempotency_key`) in einer SQLite-Transaktion, BEVOR
der Tolaria-DELETE ausgeführt wird. Nur eine Reservation pro Approval möglich.
- **DB Transaction:** `UPDATE ... SET approval_status='RESERVED' WHERE approval_status='APPROVED'`
— atomar, nur wenn noch APPROVED.
- **Crash before delete:** Approval bleibt RESERVED → Recovery: prüfen, ob DELETE
tatsächlich ausgeführt wurde (Read-Back). Wenn nicht → zurück zu APPROVED (oder
neuer Attempt mit neuem idempotency_key).
- **Crash after delete before ledger:** Read-Back zeigt Ziel absent → Ledger nachtragen,
Approval → CONSUMED.
- **Retry:** Idempotenz via `idempotency_key` — gleicher Attempt wird nicht doppelt ausgeführt.
- **Uncertain network result:** **DELETE_OUTCOME_UNKNOWN** — der Executor darf bei
Unsicherheit NICHT blind wiederholen. Er meldet OUTCOME_UNKNOWN und wartet auf
manuelle Klärung (Read-Back).
**Recovery State für DELETE_OUTCOME_UNKNOWN:** Approval bleibt RESERVED; ein manueller
Read-Back entscheidet, ob CONSUMED (Ziel absent) oder zurück zu APPROVED (Ziel noch da).
---
## 10. SINGLE USE (Phase 10)
**Approval Lifecycle:**
```
CREATED → VERIFIED → RESERVED → EXECUTED → CONSUMED
```
| Zustand | Verhalten |
|---|---|
| **INVALID** | DELETE_DENIED (Signatur/Binding-Fehler) |
| **EXPIRED** | DELETE_DENIED (expires_at überschritten) |
| **REVOKED** | DELETE_DENIED (Key oder Approval revoked) |
| **FAILED** | DELETE_DENIED (Execution-Fehler) |
| **OUTCOME_UNKNOWN** | Kein blinder Retry; manuelle Klärung |
**Eine Approval darf nach CONSUMED niemals erneut ausführbar sein.** Der consumed-check
ist Teil der Verification (§8, Punkt 15) und der Reservation (§9).
---
## 11. STORAGE (Phase 11)
**Was wird gespeichert:**
- Payload (kanonisch)
- Signature (Ed25519, Base64-URL)
- Public Key ID (KEY_ID)
- Verification Result (PASS/DENIED + Reason)
- consumed_at
- execution_attempt (attempt_id, idempotency_key)
- Result (DELETE_OK / DELETE_OUTCOME_UNKNOWN / DELETE_DENIED)
**Was wird NICHT gespeichert:**
- Private Key
- Signing Secret
**DB-Manipulationsrisiko:** Eine DB-Zeile allein darf keine gültige Approval erzeugen.
Selbst vollständige Schreibkontrolle über die Approval-DB darf ohne Christians
Private Key keine neue gültige Approval erzeugen können (Signatur fehlt → DENIED).
---
## 12. ISOLATED IMPLEMENTATION (Phase 12)
> ⚠️ NUR Code + Tests. KEIN produktives Deployment.
**Modular (keine Vermischung mit Tolaria HTTP):**
- `approval_payload.py` — kanonischer Payload, Canonicalization, Encoding, Hash
- `approval_signature.py` — Ed25519 sign/verify (synthetische Test-Keypairs)
- `approval_verifier.py` — Verification-Logik (alle §8-Checks)
- `approval_state.py` — Lifecycle (CREATED→VERIFIED→RESERVED→EXECUTED→CONSUMED), Single-Use, Reservation
**Bestehende C5-Invarianten erhalten.** Keine Änderung an Tolaria-HTTP-Code.
---
## 13. TEST CONTRACT (Phase 13)
> ⚠️ NUR synthetische Test-Keypairs. KEINE echten Keys.
**T1T30 (siehe Freigabe):**
- T1 gültige Christian-Approval → PASS
- T2 falsche Signatur → DENIED
- T3 falscher Public Key → DENIED
- T4 Payload nach Signatur verändert → DENIED
- T5 Object verändert → DENIED
- T6 Path verändert → DENIED
- T7 Commit verändert → DENIED
- T8 Provenance verändert → DENIED
- T9 Mission verändert → DENIED
- T10 Request-ID verändert → DENIED
- T11 Nonce verändert → DENIED
- T12 expired → DENIED
- T13 future-issued invalid → DENIED
- T14 falsche Operation → DENIED
- T15 unbekannte Version → DENIED
- T16 malformed payload → DENIED
- T17 malformed signature → DENIED
- T18 Replay → DENIED
- T19 consumed Approval → DENIED
- T20 cross-object reuse → DENIED
- T21 cross-mission reuse → DENIED
- T22 DB fake row ohne Signature → DENIED
- T23 DB fake row mit erfundener Signature → DENIED
- T24 DELETE Executor kann keine Approval erzeugen
- T25 RQ besitzt keinen Signing Key
- T26 SAVE Executor besitzt keinen Signing Key
- T27 Logs enthalten keinen Private Key
- T28 DB enthält keinen Private Key
- T29 Retry nach confirmed delete führt nicht zu zweitem Delete
- T30 OUTCOME_UNKNOWN führt nicht zu blindem Retry
**Adversarial:** canonicalization ambiguity, duplicate JSON keys, Unicode normalization,
path normalization, signature substitution, key-id substitution, oversized payload,
malformed timestamps, nonce collision, stale state, concurrent execution.
---
## 14. TEST SENSITIVITY (Phase 14)
**Mutationen (jede muss Tests ROT machen):**
- A Signaturprüfung entfernen
- B Object Binding entfernen
- C Commit Binding entfernen
- D Nonce Binding entfernen
- E Expiry entfernen
- F consumed-check entfernen
- G Operation-check entfernen
- H Mission Binding entfernen
- I Provenance Binding entfernen
- J Replay-Schutz entfernen
---
## 15. REGRESSION (Phase 15)
Volle relevante C5-Test-Suite. Keine bestehenden Sicherheitsinvarianten abschwächen.
Baseline-Fails separat beweisen. Keine unrelated Reparaturen.
---
## 16. SECURITY REVIEW (Phase 16)
- NO_PRIVATE_KEY_IN_REPO / ENV / DB / LOGS
- RQ_CANNOT_SIGN / HERMES_CANNOT_SIGN / RAIN_CANNOT_SIGN / SAVE_EXECUTOR_CANNOT_SIGN / DELETE_EXECUTOR_CANNOT_SIGN
- DELETE_EXECUTOR_VERIFY_ONLY
- SIGNATURE_FAIL_CLOSED / REPLAY_FAIL_CLOSED / EXPIRY_FAIL_CLOSED / STATE_FAIL_CLOSED
---
## 17. COMMIT / PUSH (Phase 17)
Nur AUTH.3D-relevante Dateien. Vor Commit: git diff, git status, Secret-Scan,
Private-Key-Pattern-Scan. Keine echten Keys. Tests nur mit synthetischen Test-Keypairs.
FF-only Push. HEAD == origin/main verifizieren.
---
## 18. FRESH CHECKER (Phase 18)
> ⚠️ Wird als unabhängiger Subagent ausgeführt.
---
## 19. FINAL REPORT (Phase 19)
> ⚠️ Wird nach Fresh Checker ausgefüllt.

View file

@ -0,0 +1,714 @@
# AUTH.3E — EXECUTOR COMMAND CHANNEL + RUNTIME BOUNDARY CONTRACT
**Status:** DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY (KEIN produktives Deployment)
**Datum:** 2026-08-27
**Mission Type:** SECURITY DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY
**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) + AUTH.3B + AUTH.3C + AUTH.3D (9c8d239) = COMPLETE
> ⚠️ Dies ist ein **Design-/Code-/Test-Dokument**. Es wird KEIN produktiver Umbau
> ausgeführt, kein Container erstellt, keine Compose/Network/Volume/DB geändert,
> kein echtes Credential erzeugt, keine ENV geändert, kein Deployment, keine
> produktive Tolaria-Mutationsprobe, keine Hermes-Autonomie. Bei Zweifel:
> FAIL CLOSED + HARD STOP.
---
## 1. CURRENT RUNTIME DISCOVERY (Phase 2) — rekonstruiert (READ-ONLY)
> Keine Annahmen. UNKNOWN bleibt UNKNOWN. Alle Fakten aus Code-Reading verifiziert.
### 1a. SAVE-Pfad: RQ/Hermes → C5 CLI → C5CPropagator → TolariaClient.write()
```
RQ/Hermes (Container 06a4a73573d0, User hermes uid 10010)
├─ [Entry Point] rq_c5_cli.py (argparse CLI, manuell aufgerufen)
│ KEIN CLI-Command führt C5CPropagator mit save_token aus (verifiziert)
├─ [Library] rq_c5c.py C5CPropagator (Z.497)
__init__(store, reader, client=None, ...) → client = client or TolariaClient()
│ Default: credential-frei (kein save_token)
│ write() → self.client.write(vault_path, after_content) (Z.638)
└─ [Library] rq_c5c.py TolariaClient.write() (Z.322)
token = self._require_token(self.save_token, "SAVE") # fail-closed
→ POST /api/vault/save (Bearer save_token)
```
**Boundary-Fakten (SAVE):**
- **Process Boundary:** C5CPropagator läuft im RQ/Hermes-Prozess (kein separater Prozess).
- **Container Boundary:** RQ-Container `06a4a73573d0` (User `hermes`, uid 10010, gid 10000).
- **Filesystem Boundary:** `/opt/data` (RQ-Container-Dateisystem); C5-DB `/opt/data/c5c_live/c5a.db`.
- **ENV Boundary:** `TOLARIA_SAVE_TOKEN` ABSENT im RQ-Container (verifiziert). `C5A_DB` steuert DB-Pfad.
- **Network Boundary:** Tolaria `187.124.31.123:5173` (auch 10.0.4.1/10.0.9.1); Forgejo 10.0.4.1:3000/10.0.9.1:3000.
- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Commit-State, ObjectChanges, Approvals, Ledger).
- **Forgejo-Zugriff:** GitReader liest Repo `nexo312/trading-system-docs` (Forgejo-Git-Push-Key).
- **Tolaria-Zugriff:** `write()` (SAVE) via `TolariaClient`.
- **Approval-DB-Zugriff:** C5AStore (delete_approvals-Tabelle) — SAVE-Pfad liest/schreibt sie nicht aktiv.
- **Benötigte Inputs:** Commit-SHA, ObjectChanges, Source-/Provenance-Daten aus Forgejo.
- **Erzeugte Outputs:** Tolaria-SAVE, C5-State-Update (PROPAGATING→VERIFYING→UPDATING_SEARCH).
### 1b. DELETE-Pfad: RQ/Hermes → C5 CLI → DeleteExecutor → Approval Validation → TolariaClient.delete()
```
RQ/Hermes (Container 06a4a73573d0)
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_approve (Z.355) → persistiert Human-Approval
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_execute (Z.424) → führt DELETE aus
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_replay (Z.458) → idempotenter Replay
├─ [Helper] rq_c5_cli.py _delete_executor (Z.344)
│ TolariaClient(base_url, delete_token=os.environ.get(ENV_TOLARIA_DELETE_TOKEN))
│ → liest NUR DELETE-Token (kein SAVE-Token)
├─ [Library] rq_c5_delete.py DeleteExecutor (Z.83)
__init__(store, client=None) → client = client or TolariaClient() (Default credential-frei)
│ execute(commit_sha) (Z.192):
│ Gate 1: ObjectChange existiert, operation == DELETE
│ Gate 2: Commit-Status == ST_DELETE_APPROVED
│ Gate 3: Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad)
│ Gate 4: Pre-Delete-Drift-Check (Ziel existiert noch)
│ → _do_delete() → self.client.delete() (DELETE)
└─ [Library] rq_c5c.py TolariaClient.delete() (Z.~352)
token = self._require_token(self.delete_token, "DELETE") # fail-closed
→ POST /api/vault/delete (Bearer delete_token)
```
**Boundary-Fakten (DELETE):**
- **Process Boundary:** DeleteExecutor läuft im RQ/Hermes-Prozess (kein separater Prozess).
- **Container Boundary:** RQ-Container `06a4a73573d0`**SELBER Container wie SAVE**.
- **Filesystem Boundary:** `/opt/data`; C5-DB `/opt/data/c5c_live/c5a.db`.
- **ENV Boundary:** `TOLARIA_DELETE_TOKEN` ABSENT im RQ-Container (verifiziert).
- **Network Boundary:** Tolaria `187.124.31.123:5173`.
- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Approvals, Ledger, Commit-State).
- **Forgejo-Zugriff:** GitReader (Provenance-/Object-Daten).
- **Tolaria-Zugriff:** `delete()` (DELETE) via `TolariaClient`.
- **Approval-DB-Zugriff:** C5AStore delete_approvals-Tabelle (lesen + USED markieren).
- **Benötigte Inputs:** Commit-SHA, persistierte Approval, ObjectChange, Nonce.
- **Erzeugte Outputs:** Tolaria-DELETE, Approval→USED, Commit→ST_UPDATING_SEARCH.
### 1c. Kernbefund (bestätigt AUTH.3B/3C)
**SAVE- und DELETE-Caller laufen im SELBEN Prozess/Container** (`rq_c5_cli.py`, RQ-Container
`06a4a73573d0`). **CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY.** Sobald Credentials
produktiv in denselben Container injiziert würden, wäre RQ_SAVE_ACCESS/DELETE_ACCESS
technisch NICHT mehr NO. → AUTH.4 BLOCKED.
### 1d. DB-Schema (C5AStore, `c5a.db`) — rekonstruiert
- **Tabellen:** `commits` (commit_sha, parent_sha, discovered_at, sequence, status, retry_count, last_error, last_error_code, updated_at), `objects` (commit_sha, object_id, path_before, path_after, operation, content_hash_before/after, metadata_hash_before/after, representation, state, reason_code; UNIQUE(commit_sha, object_id, operation)), `delete_approvals` (approval_id, workflow_commit, object_change_id, object_id, path, operation='DELETE', approval_status APPROVED/USED/REVOKED, approved_by, approval_nonce, approved_at, used_at; UNIQUE(workflow_commit, object_change_id, object_id, path)), `kv` (key, value — für search_source_provenance).
- **State Machine (commits.status):** DISCOVERED → VALIDATING → READY → PROPAGATING_TOLARIA → VERIFYING_TOLARIA → UPDATING_SEARCH → VERIFYING_SEARCH → APPLIED; plus RETRY_PENDING, FAILED, DEAD, HUMAN_REVIEW_REQUIRED, WAITING_FOR_PREDECESSOR, DELETE_APPROVED, DELETING.
- **Locks/Transactions:** SQLite; `_init_schema` erstellt Tabellen; `upsert_commit` nutzt `INSERT ... ON CONFLICT DO UPDATE` (idempotent); `add_object_change` nutzt `INSERT OR IGNORE` (idempotent per UNIQUE). Kein expliziter WAL-Modus im Code gesehen (Default SQLite).
- **Idempotenz:** `SyncStateMachine.idempotency_check` (commit_sha+object_id+operation; ALREADY_APPLIED/ALREADY_AT_TARGET/RETRY_SAFE/CONFLICT); `add_object_change` INSERT OR IGNORE; `upsert_commit` ON CONFLICT.
- **Retry:** `retry_commit` (increment_retry, max_retries → DEAD, sonst RETRY_PENDING + backoff); `_is_retryable` in C5C (nur TolariaUnavailableError retryable).
- **Replay:** `c5-delete-replay` (idempotent, nutzt denselben `_delete_executor`); Approval single-use (→ USED).
- **Recovery:** `c5e-recover`, `c5e-reconcile` (C5E); `c5e-replay --allow-writes` nur Test-/Canary-Scope.
- **Provenance:** `persist_search_source_provenance` (kv-Tabelle, key `search_source_provenance:<workflow_commit_sha>`); `get_source_provenance`.
- **Mission Binding:** **FEHLT** in C5A-Commits/ObjectChanges (kein mission_id-Feld). Nur in AUTH.3D-Approval-Payload (mission_id) vorhanden.
- **Object Binding:** `objects.object_id` (UNIQUE mit commit_sha+operation); Approval object_id-bound.
- **Commit Binding:** `commits.commit_sha`; Approval workflow_commit-bound.
### 1e. AUTH.3D-Approval (rekonstruiert, `approval_payload.py`/`approval_state.py`/`approval_verifier.py`)
- **Payload (12 kanonische Felder):** approval_version, approval_id, mission_id, delete_request_id, operation, object_id, vault_path, expected_commit, expected_provenance_hash, nonce, issued_at, expires_at.
- **Lifecycle:** CREATED → VERIFIED → RESERVED → EXECUTED → CONSUMED (atomare Reservation, TOCTOU-sicher).
- **Verifier:** verify-only (Ed25519 PUBLIC KEY ONLY), importiert nur key_id, verify.
- **Signature:** `approval_signature.py` enthält sign()/private_key_to_pem() NUR zur Simulation; KEIN Private Key im Stack.
- **Bindings:** Object, Commit, Path, Operation, Single-Use, Nonce, Expiry, Mission (in Payload).
---
## 2. COMMAND CHANNEL OPTIONS (Phase 3) — Bewertung
> Keine neue Infrastruktur bevorzugen, wenn eine einfachere bestehende Primitive
> die Sicherheitsanforderungen erfüllt.
| Kriterium | A) Shared SQLite Job DB | B) Getrennte SQLite Inbox pro Executor | C) Filesystem Inbox / atomic rename | D) Interner HTTP Executor Endpoint | E) RabbitMQ / Broker | F) Unix Socket / IPC | G) C5 State Machine (bestehend) |
|---|---|---|---|---|---|---|---|
| SECURITY | Mittel | **Hoch** | Mittel | Mittel | Hoch | Mittel | **Hoch** |
| COMPLEXITY | Niedrig | **Niedrig** | Niedrig | Mittel | **Hoch** | Mittel | **Niedrig** |
| FAIL_CLOSED | Ja | **Ja** | Ja | Ja | Ja | Ja | **Ja** |
| AUDITABILITY | Hoch | **Hoch** | Mittel | Hoch | Hoch | Mittel | **Hoch** |
| IDEMPOTENCY | Ja | **Ja** | Teilweise | Ja | Ja | Ja | **Ja** |
| REPLAY_RESISTANCE | Teilweise | **Ja** | Teilweise | Ja | Ja | Ja | **Ja** |
| CRASH_RECOVERY | Ja | **Ja** | Ja | Ja | Ja | Ja | **Ja** |
| CONCURRENCY | Mittel (Lock) | **Hoch (getrennte DBs)** | Mittel | Hoch | Hoch | Mittel | **Hoch** |
| RQ_SCOPE_ESCALATION | Nein | **Nein** | Nein | Nein | Nein | Nein | **Nein** |
| SECRET_EXPOSURE | Kein Secret | **Kein Secret** | Kein Secret | Kein Secret | Kein Secret | Kein Secret | **Kein Secret** |
| FILESYSTEM_BOUNDARY | Geteilt | **Getrennt** | Getrennt | Getrennt | Getrennt | Getrennt | **Getrennt** |
| NETWORK_BOUNDARY | Kein Netz | **Kein Netz** | Kein Netz | Netz nötig | Netz nötig | Kein Netz | **Kein Netz** |
| AUTH3D_COMPATIBILITY | Ja | **Ja** | Ja | Ja | Ja | Ja | **Ja** |
| CURRENT_C5_COMPATIBILITY | Mittel | **Hoch** | Mittel | Niedrig | **Niedrig** | Niedrig | **Hoch** |
| AUTH4_DEPLOYMENT_COMPLEXITY | Niedrig | **Niedrig** | Niedrig | Mittel | **Hoch** | Mittel | **Niedrig** |
**Bewertung:**
- **OPTION B (getrennte SQLite Inbox pro Executor)** ist die **bevorzugte** Wahl: Sie erfüllt
die geforderte **GETRENNTE INBOX/STATE BOUNDARY** (SAVE-Executor claimt nur SAVE-Jobs,
DELETE-Executor nur DELETE-Jobs), nutzt vorhandene SQLite-Primitive (keine neue
Infrastruktur), ist fail-closed, auditierbar, idempotent, replay-resistent und
crash-recoverable. Getrennte DB-Dateien (`c5a_save.db`/`c5a_delete.db`) vermeiden
Lock-Konflikte zwischen SAVE- und DELETE-Executor.
- **OPTION A (Shared SQLite Job DB)** ist einfacher, aber teilt die DB → schwächere
DB-Boundary (SAVE-Executor könnte DELETE-Jobs sehen, wenn Scope nicht strikt getrennt).
- **OPTION C (Filesystem Inbox)** ist einfach, aber weniger robust (kein atomarer
Claim/Lease, schwächere Idempotenz/Replay-Resistenz).
- **OPTION D (HTTP Endpoint)** erfordert neue Netzwerk-Endpoints → Scope-Creep.
- **OPTION E (RabbitMQ/Broker)** erfordert neue Infrastruktur → **abgelehnt** (nicht vorhanden).
- **OPTION F (Unix Socket)** erfordert neue IPC-Infrastruktur → Scope-Creep.
- **OPTION G (C5 State Machine)** ist die bestehende Primitive für DELETE (Approval
persistiert, Nonce, Single-Use, Replay-Schutz) und wird als **Komplement** zu B genutzt:
Der DELETE-Job verweist auf die persistierte AUTH.3D-Approval; der DELETE-Executor
validiert Job UND Approval unabhängig.
**Empfehlung:** **OPTION B (getrennte SQLite Inbox pro Executor) als Command Channel**,
kombiniert mit **OPTION G (C5 State Machine)** für die DELETE-Approval-Autorität.
Keine neue Infrastruktur.
---
## 3. PREFERRED ARCHITECTURAL PROPERTY (Phase 4)
**GETRENNTE INBOX / STATE BOUNDARIES.**
```
RQ/Hermes (Container 06a4a73573d0) — credential-frei
│ credential-freier Job-Request (kein Secret)
+---------------------------+ +-----------------------------+
| c5-save-executor (Cont.) | | c5-delete-executor (Cont.) |
| SAVE credential | | DELETE credential |
| NO DELETE | | NO SAVE |
| claimt NUR SAVE-Jobs | | claimt NUR DELETE-Jobs |
| c5a_save.db (Inbox) | | c5a_delete.db (Inbox) |
+---------------------------+ +-----------------------------+
│ SAVE (Bearer save_token) │ DELETE (Bearer delete_token)
▼ ▼
Tolaria Server (187.124.31.123:5173) — erwartet SAVE+DELETE-Verifier
```
**Kein generischer Dispatcher.** Kein `command="..."`, `endpoint="..."`, `url="..."`,
`method="..."`, `shell="..."`, `python="..."`, `handler="..."`. Keine RCE-artige Semantik.
**Job Types als geschlossene Allowlist:**
- `C5_SAVE_OBJECT`
- `C5_DELETE_OBJECT`
Gegen die reale C5-Architektur geprüft: Die C5-Operationen sind `OP_CREATE`,
`OP_CONTENT_UPDATE`, `OP_METADATA_UPDATE`, `OP_STATE_UPDATE`, `OP_TAGS_UPDATE`,
`OP_SOURCE_CANONICAL_RELATION_UPDATE`, `OP_RENAME`, `OP_MOVE`, `OP_DELETE_REQUEST`,
`OP_SUPERSEDE`. Für den Command Channel werden diese auf zwei geschlossene Job-Types
abgebildet: **SAVE** (alle Nicht-DELETE-Operationen, die Tolaria-Write erfordern) und
**DELETE** (nur `OP_DELETE_REQUEST`). Kein generischer Operation-Dispatcher.
---
## 4. SAVE JOB CONTRACT (Phase 5)
### Kanonischer SAVE Job
```json
{
"job_version": 1,
"job_id": "uuid4",
"mission_id": "uuid4",
"job_type": "C5_SAVE_OBJECT",
"object_id": "string",
"vault_path": "/app/vault/...",
"source_commit": "sha256",
"provenance_hash": "sha256",
"expected_state": "string",
"created_at": "ISO8601 UTC",
"idempotency_key": "uuid4"
}
```
### Content-Entscheidung: A) Content im Job ODER B) Executor lädt selbst
**Gewählt: B) Executor rekonstruiert sicherheitsrelevante Daten selbst aus autoritativer
Source (Forgejo).**
Begründung (Manipulationsrisiko):
- Wenn Content im Job transportiert würde, könnte RQ (oder ein kompromittierter
RQ-Prozess) beliebigen Content in den Job schreiben → der SAVE-Executor würde
manipulierten Content nach Tolaria schreiben.
- **Bevorzugte Eigenschaft (aus Mission):** Executor rekonstruiert sicherheitsrelevante
Daten soweit möglich selbst aus autoritativen Quellen, statt RQ blind zu vertrauen.
- Der SAVE-Executor lädt den Content selbst aus Forgejo (source_commit + object_id →
GitReader.file_content) und validiert gegen provenance_hash/expected_state.
**RQ darf NICHT bestimmen:**
- Tolaria Base URL (Executor-Konfiguration, nicht Job)
- HTTP Method (fix: POST /api/vault/save)
- Authorization Header (Executor-Credential, nicht Job)
- Credential (Executor-Credential, nicht Job)
- beliebigen Zielendpoint (fix: /api/vault/save)
---
## 5. DELETE JOB CONTRACT (Phase 6)
### Kanonischer DELETE Job (strenger)
```json
{
"job_version": 1,
"job_id": "uuid4",
"mission_id": "uuid4",
"delete_request_id": "uuid4",
"job_type": "C5_DELETE_OBJECT",
"object_id": "string",
"vault_path": "/app/vault/...",
"expected_commit": "sha256",
"expected_provenance_hash": "sha256",
"approval_id": "uuid4",
"created_at": "ISO8601 UTC",
"idempotency_key": "uuid4"
}
```
**WICHTIG: Der DELETE Job selbst ist KEINE Approval.**
DeleteExecutor muss unabhängig laden/verifizieren (AUTH.3D Approval Artifact):
- Ed25519 Signature (verify-only, PUBLIC KEY ONLY)
- mission_id
- delete_request_id
- object_id
- vault_path
- expected_commit
- expected_provenance_hash
- nonce
- expiry
- single-use state
**Job und Approval müssen exakt zusammenpassen. Mismatch → DELETE_DENIED.**
---
## 6. TRUST MODEL (Phase 7)
### RQ_JOB_FIELDS_TRUST_LEVEL
| Feld | SAVE | DELETE | Klassifikation |
|---|---|---|---|
| job_version | TRUSTED (Schema) | TRUSTED (Schema) | Schema-Validierung |
| job_id | TRUSTED (Identität) | TRUSTED (Identität) | Idempotenz-Schlüssel |
| mission_id | VERIFY_AGAINST_SOT | VERIFY_AGAINST_SOT | Gegen Approval/Provenance |
| job_type | TRUSTED (Allowlist) | TRUSTED (Allowlist) | Geschlossene Allowlist |
| object_id | VERIFY_AGAINST_SOT | VERIFY_AGAINST_SOT | Gegen Forgejo/Approval |
| vault_path | VERIFY_AGAINST_SOT | VERIFY_AGAINST_SOT | Gegen ObjectChange/Approval + Path Safety |
| source_commit | VERIFY_AGAINST_SOT | — | Gegen Forgejo |
| provenance_hash | RECOMPUTE | VERIFY_AGAINST_SOT | Executor rechnet selbst |
| expected_state | VERIFY_AGAINST_SOT | — | Gegen Forgejo |
| expected_commit | — | VERIFY_AGAINST_SOT | Gegen Approval |
| expected_provenance_hash | — | VERIFY_AGAINST_SOT | Gegen Approval |
| approval_id | — | VERIFY_AGAINST_SOT | Gegen AUTH.3D Approval |
| delete_request_id | — | VERIFY_AGAINST_SOT | Gegen Approval |
| created_at | TRUSTED (Metadaten) | TRUSTED (Metadaten) | Audit |
| idempotency_key | TRUSTED (Idempotenz) | TRUSTED (Idempotenz) | Replay-Schutz |
**Grundsatz: DATA FROM RQ != AUTHORITY.** RQ ist Orchestrator, nicht Security Authority.
Für sicherheitskritische Felder: VERIFY_AGAINST_SOT oder RECOMPUTE.
---
## 7. JOB STATE MACHINE (Phase 8)
### Deterministische State Machine
```
CREATED → READY → CLAIMED → EXECUTING → SUCCEEDED
├→ FAILED
├→ REJECTED
└→ OUTCOME_UNKNOWN
```
**Erlaubte Transitionen (geschlossene Allowlist):**
- CREATED → READY (Schema validiert, Job akzeptiert)
- CREATED → REJECTED (Schema/Allowlist/Injection-Fehler)
- READY → CLAIMED (atomarer Claim)
- CLAIMED → EXECUTING (Claim bestätigt)
- EXECUTING → SUCCEEDED (bestätigter Erfolg)
- EXECUTING → FAILED (bestätigter Fehler, kein Mutationseffekt)
- EXECUTING → OUTCOME_UNKNOWN (unklares HTTP-Ergebnis)
- EXECUTING → REJECTED (Gate-Fehler während Execution)
**RETRYABLE:** Optional, nur sicher begründet. Für SAVE: RETRYABLE nur bei
TolariaUnavailableError (transient, kein Mutationseffekt). Für DELETE: **KEIN
RETRYABLE** — ein bereits gestarteter DELETE darf nicht wegen Lease-Expiry von einem
zweiten Worker wiederholt werden. OUTCOME_UNKNOWN → kein blinder Retry.
**Ungültige Transition → FAIL CLOSED** (InvalidTransitionError).
---
## 8. CLAIM / LEASE / CONCURRENCY (Phase 9)
### Claim-Modell
- **worker_id:** UUID des Executors (SAVE-Executor vs DELETE-Executor).
- **claim_id:** UUID pro Claim.
- **claimed_at:** Timestamp.
- **lease_until:** Timestamp (Claim-Expiry).
- **attempt_count:** Zähler.
### Atomarer Claim
Der Claim muss **atomar** sein (SQLite `UPDATE ... WHERE status='READY' AND
(lease_until IS NULL OR lease_until < now) RETURNING ...` in einer Transaktion).
Kein TOCTOU.
### Concurrency-Prüfung
- **Zwei SAVE Worker:** Nur einer kann denselben Job atomar claimen (Status-Transition
READY→CLAIMED ist atomar).
- **Zwei DELETE Worker:** Nur einer kann denselben DELETE-Job claimen. **Ein bereits
gestarteter DELETE darf NICHT einfach wegen Lease-Expiry von einem zweiten Worker
wiederholt werden.** → DELETE-Job, der in EXECUTING ist, wird bei Lease-Expiry in
OUTCOME_UNKNOWN überführt (nicht zurück zu READY), und ein zweiter Worker darf ihn
NICHT erneut claimen. Nur ein expliziter Human-/Admin-Eingriff (AUTH.3D-Approval
erneut verifizieren) kann einen DELETE-Job aus OUTCOME_UNKNOWN reaktivieren.
- **Worker Crash nach Claim:** Lease-Expiry → Job wird für SAVE wieder claimbar
(RETRYABLE), für DELETE → OUTCOME_UNKNOWN (nicht blind wiederholen).
- **Stale Claim:** Lease-Expiry erkennt stale Claims.
- **Restart / Doppelstart / Race:** Atomarer Claim + Lease verhindern Doppelverarbeitung.
---
## 9. IDEMPOTENCY (Phase 10)
### SAVE
Idempotenz über: `job_id` + `idempotency_key` + `object_id` + `source_commit`/`provenance`.
- Gleicher Job (job_id) → bereits verarbeitet → kein zweiter Write.
- Gleicher idempotency_key → idempotent (kein zweiter Write).
- Gleicher object_id + source_commit → ALREADY_AT_TARGET (Ziel-Hash erreicht).
### DELETE (strenger)
- Nach bestätigtem DELETE: gleicher Job niemals erneut mutieren.
- Bei unklarem HTTP-Ergebnis: **OUTCOME_UNKNOWN. KEIN BLINDER RETRY.**
- AUTH.3D OUTCOME_UNKNOWN-Modell bleibt erhalten.
- Approval single-use (→ USED/CONSUMED) → Replay denied.
---
## 10. JOB AUTHENTICITY / INTEGRITY (Phase 11)
### Ist Job-DB-Manipulation durch RQ relevant?
- **Für SAVE:** Ein manipulierter Job darf niemals Scope erweitern. Da der SAVE-Executor
Content selbst aus Forgejo lädt und gegen provenance_hash/expected_state validiert,
kann ein manipulierter Job keinen beliebigen Content einschleusen. Scope bleibt
geschlossen (nur SAVE, nur erlaubter Pfad).
- **Für DELETE:** Selbst vollständige Job-DB-Schreibkontrolle darf NICHT ausreichen, um
DELETE zu autorisieren. **AUTH.3D Signature bleibt die Autorität.** Ein manipulierter
DELETE-Job ohne gültige, exakt passende AUTH.3D-Approval → DELETE_DENIED.
### Zusätzliche Schutzmechanismen (bewertet)
- **Job HMAC:** NICHT nötig für DELETE (AUTH.3D-Signatur schützt die kritische
DELETE-Autorität). Für SAVE optional, aber nicht erforderlich (Content wird
rekonstruiert).
- **DB ownership / filesystem permissions:** Ja — getrennte DB-Dateien mit
Executor-spezifischen Permissions (siehe Phase 12).
- **Immutable fields after claim:** Ja — nach CLAIMED sind job_type, object_id,
vault_path, approval_id, expected_* unveränderlich (UPDATE verboten).
- **State hash / row version / compare-and-swap:** Optional für SAVE; für DELETE
wird die AUTH.3D-Signatur als Autorität genutzt (keine unnötige Kryptografie).
**Keine unnötige Kryptografie einführen, wenn die AUTH.3D-Signatur bereits die
kritische DELETE-Autorität schützt.**
---
## 11. DB / FILESYSTEM BOUNDARY (Phase 12)
### Getrennte SQLite-Dateien (bestätigt AUTH.3C)
- `c5a_save.db` — SAVE-Inbox (SAVE-Jobs, SAVE-State)
- `c5a_delete.db` — DELETE-Inbox (DELETE-Jobs, DELETE-State, Approval-Referenzen)
### Zugriffsmatrix
| DB | RQ_READ | RQ_WRITE | SAVE_EXEC_READ | SAVE_EXEC_WRITE | DELETE_EXEC_READ | DELETE_EXEC_WRITE |
|---|---|---|---|---|---|---|
| c5a_save.db | Ja (Job erzeugen) | Ja (Job erzeugen) | Ja | Ja | **NEIN** | **NEIN** |
| c5a_delete.db | Ja (Job erzeugen) | Ja (Job erzeugen) | **NEIN** | **NEIN** | Ja | Ja |
**Ziel:**
- SAVE Executor braucht keinen Zugriff auf DELETE Credential/DB, außer explizit
notwendige read-only Referenzen (keine).
- DELETE Executor braucht keinen SAVE-State.
### Linux UID/GID-/Volume-Modell (für AUTH.4, NICHT produktiv ändern)
- SAVE-Executor-Container: eigener User, Volume nur für `c5a_save.db`.
- DELETE-Executor-Container: eigener User, Volume nur für `c5a_delete.db`.
- RQ-Container: Volume für beide Inbox-DBs (nur Job-Erzeugung, kein Executor-Zugriff).
- Kein geteiltes Secret-Volume.
---
## 12. NETWORK BOUNDARY (Phase 13)
### SAVE Executor
**ALLOW:**
- Tolaria notwendige interne Adresse (187.124.31.123:5173) — SAVE + READ
- notwendige autoritative Source (Forgejo 10.0.4.1:3000) — Source-/Provenance-Read
**DENY (soweit möglich):**
- Docker Socket
- SSH
- Host Root
- Telegram
- beliebiges Internet
- DELETE-spezifische Dienste
### DELETE Executor
**ALLOW:**
- Tolaria (187.124.31.123:5173) — DELETE
- notwendige Approval-/State-Source (Forgejo 10.0.4.1:3000) — Object/Commit-Read
**DENY:**
- Docker Socket
- SSH
- Host Root
- Telegram
- beliebiges Internet
- SAVE-spezifische Credentials
### Forgejo-Runtime-Frage
**Wird Forgejo wirklich zur Runtime benötigt?**
- **SAVE:** JA — der SAVE-Executor lädt Content selbst aus Forgejo (source_commit +
object_id → GitReader.file_content). Forgejo ist die autoritative Source.
- **DELETE:** JA (für Object/Commit-Read zur Validierung). Aber: Falls der DELETE-Job
alle nötigen Daten (object_id, vault_path, expected_commit, expected_provenance_hash)
bereits enthält und die AUTH.3D-Approval diese exakt bestätigt, könnte Forgejo-Read
optional sein. **Empfehlung:** Forgejo-Read für DELETE beibehalten (Defense in depth),
aber als read-only, nicht als Write.
**Falls nicht nötig: KEIN Forgejo Runtime Access.** Für SAVE ist er nötig (Content-
Rekonstruktion). Für DELETE ist er nötig (Object/Commit-Validierung).
---
## 13. SECRET BOUNDARY (Phase 14)
### Zielmatrix (beweisen)
| Akteur | SAVE_TOKEN | DELETE_TOKEN | PRIVATE_SIGNING_KEY | PUBLIC_VERIFY_KEY |
|---|---|---|---|---|
| RQ | ABSENT | ABSENT | ABSENT | ABSENT |
| SAVE Executor | **PRESENT** | ABSENT | ABSENT | ABSENT |
| DELETE Executor | ABSENT | **PRESENT** | ABSENT | **PRESENT** |
| Rain | ABSENT | ABSENT | ABSENT | ABSENT |
| Tolaria Server | PRESENT (verifier) | PRESENT (verifier) | ABSENT | ABSENT |
**Beweis (Design):**
- RQ: credential-frei (kein SAVE/DELETE-Token, kein Private Key). Nur Job-Erzeugung.
- SAVE Executor: erhält NUR SAVE-Token (injiziert), kein DELETE-Token, kein Private Key.
- DELETE Executor: erhält NUR DELETE-Token + PUBLIC_VERIFY_KEY, kein SAVE-Token, kein
Private Key.
- Rain: kein Zugriff auf irgendein Secret (separate Container).
- Tolaria Server: hält SAVE+DELETE-Verifier-Secrets (eigene ENV), KEIN Christians
Private Key.
---
## 14. COMMAND INJECTION DEFENSE (Phase 15)
Jobs dürfen niemals enthalten oder ausführen:
- shell commands
- arbitrary executable
- Python snippets
- URLs
- HTTP headers
- credentials
- SQL
- filesystem absolute targets (außerhalb erlaubten Schemas)
- Docker commands
- SSH commands
**Enum + typed schema:**
- `job_type` ist ein Enum (`C5_SAVE_OBJECT` | `C5_DELETE_OBJECT`), kein freier String.
- Alle Felder sind typisiert (uuid, sha256-hex, ISO8601, enum).
- **Unknown job_type → REJECTED.**
- **Extra privileged fields → REJECTED** (Schema-Validierung lehnt unbekannte Felder ab).
- **Malformed schema → REJECTED.**
---
## 15. PATH SAFETY COMPOSITION (Phase 16)
AUTH.2 Path Safety bleibt serverseitig zwingend. Executor muss zusätzlich eigene
Pfadprüfung durchführen.
**Defense in depth:**
```
RQ Job → Executor validation → Tolaria AUTH.2 validation
```
- **Executor validation:** vault_path wird gegen das erlaubte Schema geprüft
(kein `..`, kein absoluter Pfad außerhalb `/app/vault`, keine Symlink-Escapes,
keine Unicode-Ambiguität, keine encoded traversal).
- **Tolaria AUTH.2 validation:** serverseitige Path Safety (bestehend, zwingend).
- **Kein Caller darf AUTH.2 Path Safety ersetzen.**
---
## 16. AUTH.3D COMPOSITION (Phase 17)
Für DELETE muss gelten (alle Faktoren, keiner ersetzt einen anderen):
```
VALID_JOB
AND VALID_STATE
AND VALID_AUTH3D_SIGNATURE
AND VALID_NONCE
AND VALID_EXPIRY
AND NOT_CONSUMED
AND VALID_OBJECT
AND VALID_PATH
AND VALID_COMMIT
AND VALID_PROVENANCE
AND DELETE_CREDENTIAL
```
erst dann: **Tolaria DELETE.**
---
## 17. FAILURE MODEL (Phase 18)
| Szenario | Verhalten |
|---|---|
| DB unavailable | FAIL CLOSED — kein Write ohne State |
| DB locked | SQLite-Lock → Retry/Fehler; kein Write ohne State |
| malformed job | REJECTED (Schema) |
| unknown version | REJECTED |
| unknown type | REJECTED |
| state mismatch | FAIL CLOSED (InvalidTransitionError) |
| source unavailable | FAIL CLOSED (kein Content-Rekonstruktions-Write) |
| Tolaria unavailable | SAVE: RETRYABLE (transient); DELETE: OUTCOME_UNKNOWN |
| 401 | FAIL CLOSED (Credential/Scope-Fehler) |
| 403 | FAIL CLOSED |
| 404 | FAIL CLOSED (Ziel fehlt) |
| 409 | FAIL CLOSED (Konflikt) |
| 500 | FAIL CLOSED |
| timeout | SAVE: RETRYABLE; DELETE: OUTCOME_UNKNOWN |
| connection reset | SAVE: RETRYABLE; DELETE: OUTCOME_UNKNOWN |
| worker crash | Lease-Expiry; SAVE: wieder claimbar; DELETE: OUTCOME_UNKNOWN |
| process kill | dito |
| container restart | Zustand aus DB; keine Sicherheitsinvariante verloren |
| duplicate job | Idempotenz (job_id/idempotency_key) |
| duplicate worker | Atomarer Claim verhindert Doppelverarbeitung |
| approval unavailable | DELETE_DENIED |
| approval invalid | DELETE_DENIED |
| approval expired | DELETE_DENIED |
| credential missing | FAIL CLOSED (kein HTTP) |
**Security-Grundsatz: Unsicherheit darf nicht zu privilegierter Mutation führen.**
---
## 18. AUDIT MODEL (Phase 19)
Jeder Job benötigt Audit Trail. Mindestens:
- job_id, mission_id, job_type, object_id
- state transitions (mit Timestamps)
- worker_id, attempt
- timestamps
- result code
- approval_id (bei DELETE)
- provenance
**NICHT loggen:**
- Credentials
- Authorization Header
- Private Key
- sensitive Approval internals, falls unnötig
**Audit muss unterscheiden:**
- **REQUESTED** (RQ hat Job erzeugt)
- **AUTHORIZED** (Executor hat Job + Approval validiert)
- **EXECUTED** (Mutation ausgeführt)
**WICHTIG: RQ REQUEST != HUMAN AUTHORIZATION.** Ein REQUESTED-Eintrag ist keine
AUTHORIZATION. Nur AUTHORIZED (nach AUTH.3D-Validierung) ist eine Autorisierung.
---
## 19. ISOLATED IMPLEMENTATION (Phase 20) — Design
> Implementiert in Code + Tests. KEIN produktiver Container. Modulare Komponenten.
### Module
- `job_schema.py` — Job-Schema-Validierung (Enum, typed, Allowlist, REJECTED)
- `job_store.py` — SQLite Inbox (getrennte DBs, atomarer Claim, Lease)
- `job_state_machine.py` — deterministische State Machine (FAIL CLOSED)
- `job_claim.py` — atomarer Claim/Lease/Concurrency
- `save_executor_core.py` — SAVE-Executor-Logik (SAVE-only, Content-Rekonstruktion)
- `delete_executor_core.py` — DELETE-Executor-Logik (DELETE-only, AUTH.3D-Validierung)
### Wiederverwendung
- Bestehende C5-Module (rq_c5a, rq_c5c, rq_c5_delete, approval_*) werden wiederverwendet,
nicht neu geschrieben.
- Keine Big-Bang-Neuschreibung.
---
## 20. TEST CONTRACT (Phase 21) — Design
> T1T40 (siehe Mission §21). Implementiert in isolierten Tests (synthetische Secrets,
> Fake-Tolaria, Temp-DB). Keine produktiven Mutationsprobes.
---
## 21. ADVERSARIAL TESTS (Phase 22) — Design
> JSON duplicate keys, Unicode path ambiguity, encoded traversal, job_type casing,
> enum confusion, integer/string confusion, oversized payload, stale mission, stale
> provenance, race claim, replay after restart, DB row manipulation, cross-executor
> DB injection, approval substitution, object substitution after claim, path
> substitution after claim, state mutation after approval, crash before HTTP, crash
> after uncertain HTTP.
---
## 22. TEST SENSITIVITY (Phase 23) — Design
> Mutationen AL (siehe Mission §23). Jede Mutation muss relevante Tests ROT machen.
> Falls nicht: Tests verstärken.
---
## 23. REGRESSION (Phase 24) — Design
> Volle relevante C5-Test-Suite + AUTH.3A-Tests + AUTH.3D-Tests. Keine bestehenden
> Sicherheitsinvarianten abschwächen. Baseline-Fails separat klassifizieren. Keine
> unrelated Reparaturen.
---
## 24. SECURITY REVIEW (Phase 25) — Design
> Explizit beweisen: RQ_CREDENTIAL_FREE, HERMES_CREDENTIAL_FREE, RAIN_CREDENTIAL_FREE,
> SAVE_EXECUTOR_SAVE_ONLY, DELETE_EXECUTOR_DELETE_ONLY, DELETE_EXECUTOR_VERIFY_ONLY,
> NO_PRIVATE_SIGNING_KEY_IN_STACK, NO_GENERIC_COMMAND_DISPATCH, NO_ARBITRARY_URL,
> NO_ARBITRARY_HTTP_METHOD, NO_ARBITRARY_HEADERS, NO_SHELL_EXECUTION,
> JOB_DOES_NOT_EQUAL_AUTHORIZATION, AUTH3D_REQUIRED_FOR_DELETE, ATOMIC_CLAIM,
> IDEMPOTENCY, REPLAY_PROTECTION, OUTCOME_UNKNOWN_FAIL_CLOSED.
---
## 25. DESIGN DOC RECONCILIATION (Phase 26) — Design
> AUTH3B_DESIGN.md, AUTH3C_DESIGN.md, AUTH3D_DESIGN.md (untracked). Entscheidungsmatrix
> (FILE, SECURITY_RELEVANCE, UNIQUE_INFORMATION, ALREADY_REPRESENTED_IN_COMMITTED_CODE/DOCS,
> STALE_INFORMATION, RECOMMENDED_SOT_ACTION; Klassifikation COMMIT/MERGE_INTO_ARCH_DOC/
> ARCHIVE/DISCARD/KEEP_UNTRACKED_TEMPORARILY). Nur Empfehlung. Keine Mutation ohne
> separate Freigabe.
---
## 26. COMMIT / PUSH (Phase 27) — Design
> Nur AUTH.3E-Code + Tests + ausdrücklich notwendige AUTH.3E-Dokumentation. Vor Commit:
> git status, git diff, Secret Scan, Private-Key Scan, Credential Pattern Scan. Keine
> echten Credentials. Nur synthetische Testwerte. FF-only Push. HEAD == origin/main
> verifizieren. Falls Hooks scheitern: Ursache analysieren. --no-verify NICHT
> automatisch verwenden.
---
## 27. FRESH CHECKER (Phase 28) — Design
> Unabhängiger Checker (32 Punkte, siehe Mission §28).
---
## 28. FINAL REPORT (Phase 29) + HARD STOP (Phase 30)
> Nach FINAL REPORT sofort STOP. Keine produktiven Mutationen, keine Container,
> keine echten Credentials, keine Hermes-Autonomie.