"""Mutationsproben für die Sicherheitsprüfungen des Kerns.

Je Probe wird genau EINE Prüfung entfernt oder umgekehrt (exakte Textersetzung,
die genau einmal vorkommen muss), die komplette Suite läuft, die roten Tests
werden mit Meldung festgehalten, danach `git checkout -- <datei>`.
Vor jeder Probe muss `git status --porcelain` leer sein (committete Basis).
Kombi-Proben (alter und neuer Text als Tupel gleicher Länge) ersetzen mehrere
Stellen derselben Datei, jede genau einmal vorhanden: damit werden redundante
Schutzschichten GEMEINSAM entfernt (eine Einzelprobe bleibt dort 0 rot, weil die
andere Schicht hält - Defense-in-depth-Nachweis wie bei M78).

Aufruf (aus dem Repo-Wurzelverzeichnis oder beliebig):
    python3 -I python/tools/mutationsproben.py <ergebnis.json> <logverzeichnis> [ID ...]
Ergebnisse werden an <ergebnis.json> angehängt (Liste), Rohausgaben je Probe
landen in <logverzeichnis>/<ID>.log. Beides gehört NICHT ins Repo.
"""
from __future__ import annotations

import json
import re
import subprocess
import sys
import time
from pathlib import Path

REPO = Path(__file__).resolve().parents[2]
A = "python/aria_router/"

# E44-Kern (profiles.tools_profile): ohne Allowlist bekommt das Home keine rw-Regel. Die Mutation stellt den
# Zustand vor E44 her (ganzes Home rw ausser hart Gesperrtem und ro-Zielen).
_E44_HOME_RW_OLD = "    rw_dirs, rw_files = [], []\n"
_E44_HOME_RW_NEW = "    rw_dirs, rw_files = build_tree(home_root, frozenset(hard_block | ro_block))\n"
# Abstieg je Allowlist-Eintrag: spart hart Gesperrtes UND ro-Ziele aus.
_E44_ALLOW_TREE_OLD = "        entry_dirs, entry_files = build_tree(entry, frozenset(hard_block | ro_block))\n"

# (ID, Datei, alter Text, neuer Text, geprüfte Eigenschaft, Marker für "richtiger Grund")
MUTATIONS = [
    ("M01", A + "authorization.py", "        if not hmac.compare_digest(expected_mac, clearance.mac):\n",
     "        if False:\n", "HMAC-Signaturprüfung der Clearance", ["INVALID_SIGNATURE"]),
    ("M02", A + "authorization.py", "        if now >= clearance.expires_at:\n", "        if False:\n",
     "Ablaufzeit der Clearance", ["EXPIRED"]),
    ("M03", A + "authorization.py",
     "        if not self._nonces.consume(clearance.nonce, clearance.expires_at, now):\n", "        if False:\n",
     "Einmal-Nonce (Replay-Schutz)", ["REPLAY"]),
    ("M04", A + "authorization.py", "        if clearance.backend != backend:\n", "        if False:\n",
     "Bindung an das Ziel-Backend", ["BACKEND_MISMATCH"]),
    ("M05", A + "authorization.py", "        if clearance.job_id != job_id:\n", "        if False:\n",
     "Bindung an die job_id", ["JOB_MISMATCH"]),
    ("M06", A + "authorization.py",
     "        if not hmac.compare_digest(clearance.context_digest, context_digest):\n", "        if False:\n",
     "Digest über den tatsächlich gesendeten Kontext", ["CONTEXT_DIGEST_MISMATCH"]),
    ("M07", A + "authorization.py", "        if clearance.privacy != privacy.value:\n", "        if False:\n",
     "Neuklassifikation im Adapter = Clearance-Klasse", ["PRIVACY_MISMATCH"]),
    ("M08", A + "authorization.py",
     "        if not self._matrix.permits(backend, privacy):\n            raise AuthorizationError(\"PRIVACY_NOT_PERMITTED\")\n        if not _HEX32",
     "        if False:\n            raise AuthorizationError(\"PRIVACY_NOT_PERMITTED\")\n        if not _HEX32",
     "Matrix-Nachprüfung im Adapter (Verifier)", ["PRIVACY_NOT_PERMITTED"]),
    ("M09", A + "authorization.py",
     "        if not self._matrix.permits(backend, privacy):\n            raise AuthorizationError(\"PRIVACY_NOT_PERMITTED\")\n        if not isinstance(context_digest",
     "        if False:\n            raise AuthorizationError(\"PRIVACY_NOT_PERMITTED\")\n        if not isinstance(context_digest",
     "Matrixprüfung beim Ausstellen (Issuer)", ["PRIVACY_NOT_PERMITTED"]),
    ("M10", A + "authorization.py",
     "        if clearance.sender_id != sender_id or clearance.chat_id != chat_id:\n", "        if False:\n",
     "Bindung an Absender und Chat", ["SUBJECT_MISMATCH"]),
    ("M11", A + "authorization.py", "        if lifetime <= 0 or lifetime > self._max_ttl:\n", "        if False:\n",
     "Maximale Lebensdauer der Clearance", ["INVALID_LIFETIME"]),
    ("M12", A + "authorization.py", "        if clearance.issued_at > now + self._skew:\n", "        if False:\n",
     "Clearance aus der Zukunft", ["NOT_YET_VALID"]),
    ("M13", A + "authorization.py", "            raise KeyUnavailableError(\"KEY_UNAVAILABLE\") from exc\n",
     "            return b\"\\0\" * 32\n", "Fail-closed bei fehlendem Schlüssel", ["KEY_UNAVAILABLE"]),
    ("M14", A + "authorization.py", "            if info.st_mode & 0o077:\n", "            if False:\n",
     "Schlüsseldatei nur für den Besitzer lesbar", ["KEY_PERMISSIONS_TOO_OPEN"]),
    ("M15", A + "gateway.py", "            if request.sender_id not in allowed:\n", "            if False:\n",
     "Absender-Allowlist", ["NOT_AUTHORIZED", "SENDER_NOT_ALLOWED"]),
    ("M16", A + "gateway.py", "            if request.chat_id != request.sender_id:\n", "            if False:\n",
     "Privatchat: chat_id == sender_id", ["CHAT_SENDER_MISMATCH", "test_private_chat_sender_mismatch"]),
    ("M17", A + "gateway.py",
     "        if parsed.kind in (CommandKind.SET_MODE, CommandKind.ONE_SHOT) and not owner:\n",
     "        if False:\n", "Modusbefehle nur für authentifizierte Besitzer", ["MODE_COMMAND_NOT_ALLOWED"]),
    ("M18", A + "gateway.py", "        if unknown:\n", "        if False:\n",
     "Selbstdeklarierte Felder an der Bridge abweisen", ["UNKNOWN_FIELD"]),
    ("M19", A + "core.py", "    if not matrix.permits(backend.name, job.privacy):\n", "    if False:\n",
     "Datenschutzmatrix im Routing", ["PRIVACY_NOT_PERMITTED", "PRIVACY_DENIED", "MANUAL_CHOICE_NOT_PERMITTED"]),
    ("M20", A + "core.py", "        chosen = [e for e in eligible if e.backend == wanted]\n",
     "        chosen = [e for e in evaluations if e.backend == wanted]\n",
     "Manuelle Wahl überstimmt keine Prüfung", ["MANUAL_CHOICE_NOT_PERMITTED", "manual"]),
    ("M21", A + "core.py", "            alternatives = _rank([e for e in eligible if e.backend != wanted])\n",
     "            alternatives = _rank([e for e in evaluations if e.backend != wanted])\n",
     "Fallback nur auf vollständig geprüftes Backend", ["test_checked_fallback"]),
    ("M22", A + "classify.py", "        self._confidential = SECRET_RULES + PII_RULES + _compile(\n",
     "        self._confidential = PII_RULES + _compile(\n", "Secret-Muster -> CONFIDENTIAL", ["secret."]),
    ("M23", A + "classify.py", "        confidential, internal = self._scan(text)\n        if confidential:\n",
     "        confidential, internal = self._scan(text)\n        if allow_public:\n"
     "            return PrivacyTier.PUBLIC, [\"public.marker\"]\n        if confidential:\n",
     "Vertrauliche Treffer schlagen die PUBLIC-Markierung", ["test_confidential_beats_marker"]),
    ("M24", A + "classify.py", "        return tier, rules + [\"attachment.never_public\"]\n",
     "        return (PrivacyTier.PUBLIC if tier is PrivacyTier.UNKNOWN else tier), rules\n",
     "Anhänge sind nie PUBLIC", ["test_attachments_never_public"]),
    ("M25", A + "gateway.py",
     "            if not self.config.privacy.matrix.permits(backend, tier):\n                rejected += 1\n",
     "            if False:\n                rejected += 1\n", "Memory-Ausschnitt einzeln gegen die Matrix",
     ["test_memory_filtered_per_snippet_for_codex"]),
    ("M26", A + "gateway.py", "        if not self.config.privacy.matrix.permits(backend, final.tier):\n",
     "        if False:\n", "Finale Neuklassifikation nach Anreicherung", ["PRIVACY_CHANGED_BY_ENRICHMENT"]),
    ("M27", A + "budget.py", "        if jobs + 1 > limit.daily_jobs:\n", "        if False:\n",
     "Tageslimit Jobs", ["test_reserve_and_settle_counts", "budget"]),
    ("M28", A + "state.py", "            fcntl.flock(lock_fd, fcntl.LOCK_EX)\n            data = self._read()\n",
     "            data = self._read()\n", "Dateisperre (Nebenläufigkeit Budget/Idempotenz/Nonce)",
     ["test_concurrent", "test_parallel"]),
    ("M29", A + "gateway.py",
     "            available = (settings.enabled and adapter is not None and health.status != \"open\"\n",
     "            available = (settings.enabled and adapter is not None\n", "Circuit-Breaker im Routing",
     ["test_codex_outage_opens_circuit_and_reroutes", "test_claude_outage_without_unchecked_fallback"]),
    ("M30", A + "health.py", "        if code not in HEALTH_FAILURE_CODES:\n", "        if False:\n",
     "Nur Backend-Fehler zählen für den Breaker", ["test_non_health_codes_ignored"]),
    ("M31", A + "gateway.py", "        if not claimed:\n", "        if False:\n",
     "Idempotenz je (chat_id, message_id)", ["DUPLICATE_MESSAGE"]),
    ("M32", A + "gateway.py",
     "            restricted = principal is Principal.GROUP_MEMBER and settings.name != \"claude\"\n",
     "            restricted = False\n", "Gruppen nur an Claude", ["test_group_message_claude_only"]),
    ("M33", A + "gateway.py", "        if not execute:\n            return GatewayResponse(\n",
     "        if False:\n            return GatewayResponse(\n", "Shadow führt nie aus",
     ["test_shadow_never_executes"]),
    ("M34", A + "runner.py", "cwd=str(workdir), env=env, shell=False", "cwd=str(workdir), env=None, shell=False",
     "Env-Allowlist, keine geerbte Umgebung", ["test_env_not_inherited"]),
    ("M35", A + "runner.py", "            if token in lowered:\n", "            if False:\n",
     "Verbotene Sandbox-Flags", ["FORBIDDEN_SANDBOX_FLAG"]),
    ("M36", A + "runner.py", "                        if len(out) + len(chunk) > spec.max_output_bytes:\n",
     "                        if False:\n", "Ausgabelimit während des Laufs", ["OUTPUT_LIMIT"]),
    ("M37", A + "runner.py", "            _kill_group(proc.pid)\n", "            pass\n",
     "Prozessgruppe vollständig beenden", ["test_process_group_killed"]),
    ("M38", A + "codex_adapter.py", "            assert_safe_argv(self._argv)  # unmittelbar vor dem Lauf erneut\n",
     "            pass\n", "Template-Prüfung unmittelbar vor dem Lauf", ["test_adapter_rechecks_template_before_run"]),
    ("M39", A + "result_check.py", "        text, count = pattern.subn(f\"[GESCHWÄRZT:{rule_id}]\", text)\n",
     "        count = 0\n", "Secret-Schwärzung in Antworten", ["GESCHWÄRZT", "REDACTED"]),
    ("M40", A + "adapter_contract.py", "            verified = verifier.verify(\n",
     "            verified = clearance if True else verifier.verify(\n",
     "Adapter-Basisklasse verifiziert vor jeder Ausführung", ["MISSING_CLEARANCE"]),
    ("M41", A + "adapter_contract.py", "            if protected in cls.__dict__:\n", "            if False:\n",
     "execute() nicht überschreibbar", ["test_subclass_cannot_override"]),
    ("M42", A + "gateway.py", "            if codex.real_execution:\n", "            if True:\n",
     "Echte Codex-Ausführung standardmäßig aus", ["test_default_runner_is_mock"]),
    ("M43", A + "config.py", "        if not wrapper:\n", "        if False:\n",
     "Echte Ausführung nur mit Isolations-Wrapper", ["isolation_wrapper"]),
    ("M44", A + "decision_log.py", "            if key in FORBIDDEN_KEYS:\n", "            if False:\n",
     "Kein Klartext im Entscheidungsprotokoll", ["test_forbidden_keys_rejected"]),
    ("M46", A + "classify.py",
     "re.compile(r\"(?<![0-9])\\d{8,10}:[A-Za-z0-9_-]{35}(?![A-Za-z0-9_-])\")",
     "re.compile(r\"\\b\\d{8,10}:[A-Za-z0-9_-]{35}\\b\")",
     "Telegram-Token auch mit '-' am Ende und in der Bot-URL", ["test_telegram_token_edge_forms",
                                                               "test_token_in_bot_url_redacted"]),
    ("M45", A + "gateway.py",
     "            self._log.write({**record, \"handling\": \"pending\", \"code\": \"CLEARANCE_ISSUED\"})\n",
     "            pass\n", "Keine Ausführung ohne Protokolleintrag", ["LOG_UNAVAILABLE", "test_execute_writes_decision"]),
    # --- Codex-Isolationskaefig (lane-codex) ---
    ("M47", A + "isolation/iso_exec.py", " memfd_create memfd_secret", " memfd_secret",
     "memfd_create-Sperre (M2)", ["test_memfd_create_denied"]),
    ("M48", A + "isolation/iso_exec.py",
     "    rule(ACT_ERRNO(EPERM), \"execveat\", ArgCmp(4, CMP_MASKED_EQ, AT_EMPTY_PATH, AT_EMPTY_PATH))\n",
     "    pass\n", "execveat(AT_EMPTY_PATH)-Sperre (M2)", ["test_execveat_empty_path_denied"]),
    ("M49", A + "isolation/iso_exec.py",
     "    if \"AS\" in lim:\n        resource.setrlimit(resource.RLIMIT_AS, (int(lim[\"AS\"]), int(lim[\"AS\"])))\n",
     "    if False:\n        resource.setrlimit(resource.RLIMIT_AS, (int(lim[\"AS\"]), int(lim[\"AS\"])))\n",
     "RLIMIT_AS im Wrapper (M3)", ["test_rlimit_as_set_in_wrapper"]),
    ("M50", A + "isolation/iso_exec.py",
     "    if \"CPU\" in lim:\n        resource.setrlimit(resource.RLIMIT_CPU, (int(lim[\"CPU\"]), int(lim[\"CPU\"])))\n",
     "    if False:\n        resource.setrlimit(resource.RLIMIT_CPU, (int(lim[\"CPU\"]), int(lim[\"CPU\"])))\n",
     "RLIMIT_CPU im Wrapper (M3)", ["test_rlimit_cpu_set_in_wrapper"]),
    ("M51", A + "isolation/iso_exec.py",
     "    if abi < min_abi:\n        die(f\"Landlock ABI {abi} < erforderlich {min_abi} (fail-closed)\")\n",
     "    if False:\n        die(f\"Landlock ABI {abi} < erforderlich {min_abi} (fail-closed)\")\n",
     "Landlock-ABI-Check fail-closed (N3)", ["test_abi_requirement_fail_closed"]),
    ("M52", A + "isolation/iso_exec.py",
     "    if stat.S_ISLNK(st.st_mode):\n        die(f\"Symlink als Konfig-Pfad abgelehnt (N4): {p}\")\n",
     "    if False:\n        die(f\"Symlink als Konfig-Pfad abgelehnt (N4): {p}\")\n",
     "Symlink-Konfigpfad-Ablehnung (N4)", ["test_symlink_config_path_rejected"]),
    ("M53", A + "isolation/profiles.py", "    return [dirs.auth_copy], []\n", "    return [], [dirs.auth_copy]\n",
     "auth-Kopie standardmaessig read-only (M1)", ["test_auth_default_readonly_vs_writable"]),
    ("M54", A + "codex_adapter.py", "        if self._require_output_file:\n", "        if False:\n",
     "last.txt-Quercheck (M3)", ["test_fail_when_output_file_missing", "test_fail_when_output_file_mismatches_jsonl"]),
    ("M55", A + "isolation/caged_runner.py", "        if not self.bus_reachable():\n", "        if False:\n",
     "Scope-Pflicht/NO_USER_BUS fail-closed (M3)", ["test_no_user_bus_is_fail_closed"]),
    # --- Phase-Fix (Astra/Opus-Re-Review), neue Pruefungen ---
    ("M61", A + "isolation/profiles.py", "    if name in SECRET_NAMES:\n", "    if False:\n",
     "#4 erweiterte Credential-Dateinamen", ["test_extended_credential_names_blocked"]),
    ("M62", A + "isolation/profiles.py",
     "            out.add(os.path.realpath(p))      # Symlink-Ziel ebenfalls sperren\n",
     "            pass  # realpath-Ziel NICHT gesperrt (Mutation)\n",
     "#5 Symlink-Hard-Block am realpath-Ziel", ["test_symlinked_hard_block_covers_target"]),
    ("M63", A + "isolation/profiles.py", "    rx_dirs, x_files = build_exec_rx(exec_roots, hard_block)\n",
     "    rx_dirs, x_files = build_exec_rx(exec_roots, frozenset(claude))\n",
     "#2 exec-root carvet Secrets (Landlock-Union)", ["test_exec_root_does_not_regrant_secret"]),
    ("M64", A + "isolation/profiles.py", "        if p and os.path.abspath(p) != home_root:\n",
     "        if False and os.path.abspath(p) != home_root:\n",
     "#6 state_dir/Repo gesperrt", ["test_state_dir_and_repo_blocked_workspace_rw"]),
    # M65: Ausschnitt an den Code seit 51d99a5 angepasst (FILESET/TOPLEVEL ergaenzt; der alte Text kam
    # dort nicht mehr vor -> SNIPPET_COUNT=0). Seit E44 Option 2 redundant (Home ohnehin nicht rw), siehe M65K.
    ("M65", A + "isolation/profiles.py",
     "        d, f = _resolve_ro_targets(home_root, STARTUP_RO_FILES + STARTUP_RO_DIRS + STARTUP_RO_FILESET\n"
     "                                   + STARTUP_RO_TOPLEVEL, STARTUP_RO_ABS)\n",
     "        d, f = (set(), set())\n",
     "H1 Startschutz read-only", ["test_protect_startup_true_makes_startup_read_only"]),
    ("M66", A + "isolation/caged_runner.py",
     "    if not (os.path.isabs(iso) and os.path.basename(iso) == \"iso_exec.py\"):\n        return False\n",
     "    if False:\n        return False\n",
     "#1 strukturelle Kaefig-Pruefung (iso_exec)", ["test_wrapper_structural_rejects_bypass"]),
    ("M67", A + "isolation/caged_runner.py",
     "            self.stop_scope(unit)\n            artifact = self._read_last(dirs, spec.max_output_bytes)\n",
     "            artifact = self._read_last(dirs, spec.max_output_bytes)\n            self.stop_scope(unit)\n",
     "#7 Scope-Stop vor last.txt-Lektuere", ["test_scope_stopped_before_read"]),
    ("M68", A + "isolation/iso_exec.py",
     "    for cmd in (F_SETOWN, F_SETOWN_EX, F_SETSIG):\n        rule(ACT_ERRNO(EPERM), \"fcntl\", ArgCmp(1, CMP_EQ, cmd, 0))\n",
     "    for cmd in ():\n        rule(ACT_ERRNO(EPERM), \"fcntl\", ArgCmp(1, CMP_EQ, cmd, 0))\n",
     "#8b fcntl F_SETOWN/SETSIG gesperrt", ["test_fcntl_setown_foreign_denied", "test_fcntl_setsig_denied"]),
    ("M69", A + "isolation/iso_exec.py",
     "    rule(ACT_ERRNO(EPERM), \"prlimit64\", ArgCmp(0, CMP_NE, 0, 0))\n",
     "    pass  # prlimit64-Regel entfernt (Mutation)\n",
     "#8c prlimit64 nur pid==0", ["test_prlimit_foreign_denied"]),
    ("M70", A + "isolation/iso_exec.py", " memfd_create memfd_secret process_madvise", " memfd_create memfd_secret",
     "#8 process_madvise gesperrt", ["test_process_madvise_denied"]),
    ("M71", A + "runner.py", "                            error = \"STDIN_FAILED\"\n                            break\n",
     "                            pending = pending[:0]\n",
     "#13 STDIN_FAILED bei EPIPE", ["test_stdin_write_failure_is_error"]),
    ("M72", A + "codex_adapter.py", "            if last != expected:\n",
     "            if last != expected and last not in expected and expected not in last:\n",
     "#14 exakter last.txt-Quercheck", ["test_substring_last_txt_rejected"]),
    ("M73", A + "isolation/caged_runner.py",
     "        if spec.files:\n            return RunResult(False, \"ATTACHMENTS_UNSUPPORTED\")\n",
     "        if False:\n            return RunResult(False, \"ATTACHMENTS_UNSUPPORTED\")\n",
     "#15 Anhaenge fail-closed", ["test_files_fail_closed"]),
    ("M74", A + "classify.py", "        tier = most_sensitive((text_tier, source_tier))\n",
     "        tier = text_tier\n",
     "#16 Memory-Quellname klassifiziert", ["test_memory_source_label_classified"]),
    ("M75", A + "isolation/caged_runner.py",
     "def _raise_terminated(signum, frame):\n    raise _BridgeTerminated()\n",
     "def _raise_terminated(signum, frame):\n    return\n",
     "#9 SIGTERM stoppt den Scope", ["test_sigterm_stops_scope_and_grandchild"]),
    # --- Re-Review 2 (verzoegerte Ausfuehrung), neue Startschutz-Pruefungen ---
    ("M76", A + "isolation/profiles.py",
     '    ".cargo/credentials", ".cargo/credentials.toml", ".claude.json",\n',
     '    ".cargo/credentials", ".cargo/credentials.toml",\n',
     "1b .claude.json hart gesperrt", ["test_claude_json_hard_blocked", "test_claude_json_read_denied"]),
    ("M77", A + "isolation/profiles.py",
     "        out.add(os.path.abspath(p))\n", "        pass\n",
     "1a Top-Level-Dotfiles ro", ["test_top_level_dotfiles_read_only"]),
    # M78: der Secret-Ausschluss des Top-Level-Sweeps ist belt-and-suspenders (Secret-Name ODER
    # hard_block). Nur das Entfernen BEIDER Checks nimmt die Schutzeigenschaft ("Secrets nie im Sweep")
    # materiell weg -> dann wuerde .env als ro lesbar. Einzel-Mutation je Check laesst den anderen greifen
    # (als Defense-in-depth belegt: eine solche Einzel-Mutation war 0 rot).
    ("M78", A + "isolation/profiles.py",
     "        if is_dot and _is_secret_name(name):\n"
     "            continue                              # Secret -> hart gesperrt, NICHT ro (bliebe sonst lesbar)\n"
     "        if os.path.abspath(p) in hard_block:\n            continue\n",
     "        if False:\n            continue\n        if False:\n            continue\n",
     "1a Secret-Dotfiles bleiben vom Sweep ausgeschlossen (nicht ro lesbar)",
     ["test_secret_top_level_dotfile_stays_hard_blocked", "test_credentials_blocked",
      "test_tools_blocks_credentials_and_claude"]),
    ("M79", A + "isolation/profiles.py",
     'STARTUP_RO_DIRS = (".config", "scripts", ".local", ".nvm", ".bun", ".cargo/bin", "brain", "state")\n',
     'STARTUP_RO_DIRS = (".config", "scripts", "brain")\n',
     "1c Paketmanager/.local/state ro", ["test_package_dirs_and_cargo_registry_read_only",
                                         "test_deferred_exec_ro_targets_denied_rw_ok",
                                         "test_rejects_startup_protected_dirs"]),
    ("M80", A + "isolation/profiles.py",
     "                if (ap == home_root or ap.startswith(home_root + os.sep)) and os.path.isfile(ap):\n",
     "                if False:\n",
     "1d gesourcte Dateien ro", ["test_sourced_files_read_only"]),
    ("M81", A + "isolation/profiles.py",
     "            (ro_dirs if os.path.isdir(rp) else ro_files).add(ap)\n", "            pass\n",
     "1e Cron-Ziele ro", ["test_cron_targets_read_only", "test_build_time_rejects_entry_inside_cron_dir",
                          "test_allowlist_carves_secret_hooks_and_cron_targets",
                          "test_write_only_in_workspace_and_allowlist"]),
    ("M82", A + "isolation/profiles.py",
     '        return [], f"crontab_read_failed:{type(exc).__name__}"\n', "        return [], None\n",
     "1e Cron-Lesefehler -> Warnung", ["test_cron_read_failure_yields_warning_not_crash"]),
    ("M83", A + "isolation/profiles.py",
     "        if is_worktree or is_family:\n", "        if False:\n",
     "1g Geschwister-Worktrees/Familie gesperrt", ["test_sibling_worktrees_and_family_blocked"]),
    ("M84", A + "isolation/profiles.py",
     "STARTUP_RO_ABS = ()", 'STARTUP_RO_ABS = ("/home/aria/aria",)',
     "1f totes STARTUP_RO_ABS entfernt", ["test_startup_ro_abs_has_no_dead_entry"]),
    # --- E44 Option 2 (Kais TG 12923): Home nur lesbar, Schreiben nur Lauf-Verzeichnisse + Allowlist ---
    ("M85", A + "isolation/profiles.py", _E44_HOME_RW_OLD, _E44_HOME_RW_NEW,
     "E44-Kern: Home ausserhalb der Allowlist nicht schreibbar",
     ["test_without_allowlist_home_is_read_only", "test_kostenwache_class_closed",
      "test_write_only_in_workspace_and_allowlist", "test_home_readable_but_not_writable"]),
    ("M86", A + "isolation/profiles.py", _E44_ALLOW_TREE_OLD,
     "        entry_dirs, entry_files = build_tree(entry, hard_block)\n",
     "E44: ro-Ziele (Hooks/Cron) bleiben im Allowlist-Ordner ro",
     ["test_allowlist_carves_secret_hooks_and_cron_targets", "test_allowlist_dir_keeps_startup_carveouts",
      "test_write_only_in_workspace_and_allowlist"]),
    ("M87", A + "isolation/profiles.py", _E44_ALLOW_TREE_OLD,
     "        entry_dirs, entry_files = build_tree(entry, frozenset(ro_block))\n",
     "E44: Secrets bleiben im Allowlist-Ordner hart gesperrt",
     ["test_allowlist_carves_secret_hooks_and_cron_targets",
      "test_protect_startup_off_opens_hooks_only_inside_allowlist", "test_write_only_in_workspace_and_allowlist"]),
    ("M88", A + "isolation/profiles.py",
     "        if any(part.startswith(\".\") for part in os.path.relpath(raw, home_root).split(os.sep)):\n",
     "        if False:\n",
     "E44-Validierung: keine versteckte Komponente (.cache/.cargo/.git ...)",
     ["test_rejects_hidden_components", "test_invalid_entry_fails_closed_with_clear_error"]),
    ("M89", A + "isolation/profiles.py",
     "            if _inside_or_equal(raw, p):\n                raise WriteAllowlistError(f\"{raw}: inside blocked area {p}\")\n",
     "            if False:\n                raise WriteAllowlistError(f\"{raw}: inside blocked area {p}\")\n",
     "E44-Validierung: nicht in gesperrten/Startschutz-Bereichen",
     ["test_rejects_startup_protected_dirs", "test_rejects_state_dir_repo_and_family",
      "test_rejects_credential_symlink_target"]),
    ("M90", A + "isolation/profiles.py",
     "            if _inside_or_equal(raw, p):\n"
     "                raise WriteAllowlistError(f\"{raw}: inside read-only protected area {p}\")\n",
     "            if False:\n"
     "                raise WriteAllowlistError(f\"{raw}: inside read-only protected area {p}\")\n",
     "E44-Validierung: nicht in ro-Zielen des Profilbaus (Cron/Hooks)",
     ["test_rejects_dynamic_blocked_and_read_only", "test_build_time_rejects_entry_inside_cron_dir"]),
    ("M91", A + "isolation/profiles.py", "        if os.path.realpath(raw) != raw:\n", "        if False:\n",
     "E44-Validierung: kein Symlink im Pfad",
     ["test_symlinked_ancestor_cannot_smuggle_protected_area", "test_rejects_missing_file_and_symlinks"]),
    ("M92", A + "isolation/profiles.py", "        if not raw.startswith(home_root + os.sep):\n", "        if False:\n",
     "E44-Validierung: echt unter home_root (Meldung; Ablehnung haelt zusaetzlich die Punkt-Regel)",
     ["test_rejects_home_root_and_outside"]),
    ("M93", A + "isolation/profiles.py",
     "            if _inside_or_equal(raw, p) or _inside_or_equal(p, raw):\n", "            if False:\n",
     "E44-Validierung: keine Ueberlappung mit workspace_root",
     ["test_rejects_overlap_with_run_container", "test_overlap_with_workspace_root_rejected"]),
    ("M94", A + "isolation/profiles.py", "        if os.path.normpath(raw) != raw:\n", "        if False:\n",
     "E44-Validierung: kanonischer Pfad (Meldung; Ablehnung haelt zusaetzlich die realpath-Pruefung)",
     ["test_rejects_relative_and_non_canonical"]),
    ("M95", A + "isolation/profiles.py",
     "    allow = validate_write_allowlist(write_allowlist, home_root=home_root, state_dir=state_dir,\n"
     "                                     repo_root=repo_root, runs_root=runs_root,\n"
     "                                     blocked=hard_block, read_only=ro_block)\n",
     "    allow = tuple(write_allowlist)\n",
     "E44: Profilbau prueft die Allowlist je Lauf erneut",
     ["test_build_time_rejects_entry_inside_cron_dir",
      "test_entry_swapped_for_symlink_after_construction_fails_closed"]),
    ("M96", A + "isolation/caged_runner.py", "            except P.WriteAllowlistError:\n", "            except ():\n",
     "E44: klarer Laufzeit-Fehlercode WRITE_ALLOWLIST_INVALID",
     ["test_entry_swapped_for_symlink_after_construction_fails_closed"]),
    ("M97", A + "isolation/caged_runner.py",
     "        self.write_allowlist = P.validate_write_allowlist(\n"
     "            list(write_allowlist), home_root=self.home_root, state_dir=self.state_dir,\n"
     "            repo_root=self.repo_root, runs_root=os.path.abspath(str(self.workspace_root)))\n",
     "        self.write_allowlist = tuple(write_allowlist)\n",
     "E44: Runner prueft die Allowlist schon beim Bau", ["test_runner_validates_at_construction"]),
    ("M98", A + "config.py",
     "        write_allowlist = validate_write_allowlist(\n"
     "            list(allow_raw), home_root=str(home_root),\n"
     "            state_dir=str(state_dir) if state_dir is not None else None, repo_root=PKG_REPO_ROOT,\n"
     "            runs_root=str(workspace) if workspace is not None else None)\n",
     "        write_allowlist = tuple(allow_raw)\n",
     "E44: Konfiguration prueft die Allowlist fail-closed",
     ["test_invalid_entry_fails_closed_with_clear_error", "test_inside_state_dir_rejected"]),
    ("M99", A + "config.py", "    if allow_raw and profile != \"tools\":\n", "    if False:\n",
     "E44: Allowlist nur im tools-Profil (Konfiguration)", ["test_text_profile_rejects_allowlist"]),
    ("M100", A + "isolation/caged_runner.py", "        if profile != \"tools\" and write_allowlist:\n",
     "        if False:\n", "E44: Allowlist nur im tools-Profil (Runner)",
     ["test_runner_text_profile_rejects_allowlist"]),
    # Kombi-Proben: seit E44 redundante Startschutz-Schichten (M65/M77 einzeln 0 rot erwartet) zusammen mit
    # dem E44-Kern entfernen -> die alte Schicht muss dann wieder als einzige halten und rot werden.
    ("M65K", A + "isolation/profiles.py",
     (_E44_HOME_RW_OLD,
      "        d, f = _resolve_ro_targets(home_root, STARTUP_RO_FILES + STARTUP_RO_DIRS + STARTUP_RO_FILESET\n"
      "                                   + STARTUP_RO_TOPLEVEL, STARTUP_RO_ABS)\n"),
     (_E44_HOME_RW_NEW, "        d, f = (set(), set())\n"),
     "H1 Startschutz traegt wieder, wenn der E44-Kern fehlt", ["test_protect_startup_true_makes_startup_read_only"]),
    ("M77K", A + "isolation/profiles.py",
     (_E44_HOME_RW_OLD, "        out.add(os.path.abspath(p))\n"), (_E44_HOME_RW_NEW, "        pass\n"),
     "1a Top-Level-Dotfiles ro traegt wieder, wenn der E44-Kern fehlt", ["test_top_level_dotfiles_read_only"]),
]


def git(*args: str) -> str:
    return subprocess.run(["git", *args], cwd=REPO, capture_output=True, text=True, check=True).stdout


def parse_failures(output: str) -> list[dict]:
    failures = []
    for block in re.split(r"^=+$", output, flags=re.M):
        match = re.search(r"^(FAIL|ERROR): (\S+) \(([^)]+)\)", block, flags=re.M)
        if not match:
            continue
        lines = [line for line in block.strip().splitlines() if line.strip() and not line.startswith("---")]
        message = next((line for line in reversed(lines) if not line.startswith("  File ")), "")
        failures.append({"kind": match.group(1), "test": match.group(3), "message": message[:400],
                         "block": block.strip()[-2000:]})
    return failures


def run_probe(mutation, log_dir: Path) -> dict:
    ident, rel, old, new, prop, markers = mutation
    if git("status", "--porcelain").strip():
        raise SystemExit("Worktree nicht sauber - Abbruch vor " + ident)
    path = REPO / rel
    source = path.read_text(encoding="utf-8")
    combo = isinstance(old, tuple)
    pairs = list(zip(old, new)) if combo else [(old, new)]
    if combo and len(old) != len(new):
        raise SystemExit("Kombi-Probe mit ungleich vielen Ersetzungen: " + ident)
    counts = [source.count(o) for o, _ in pairs]
    count = counts if combo else counts[0]
    result = {"id": ident, "file": rel, "property": prop, "snippet_count": count}
    if any(c != 1 for c in counts):
        result.update({"applied": False, "red": None, "error": f"SNIPPET_COUNT={count}"})
        return result
    started = time.monotonic()
    mutated = source
    for o, n in pairs:
        mutated = mutated.replace(o, n)
    path.write_text(mutated, encoding="utf-8")
    try:
        proc = subprocess.run(
            ["timeout", "900", sys.executable, "-B", "-I", "-m", "unittest", "discover", "-s", "tests", "-t", "."],
            cwd=REPO / "python", capture_output=True, text=True,
        )
    finally:
        git("checkout", "--", rel)
    output = proc.stdout + proc.stderr
    (log_dir / f"{ident}.log").write_text(output, encoding="utf-8")
    if git("status", "--porcelain").strip():
        raise SystemExit("Zurücksetzen fehlgeschlagen nach " + ident)
    failures = parse_failures(output)
    ran = re.search(r"Ran (\d+) tests?", output)
    marker_hits = sorted({m for m in markers for f in failures if m in f["block"] or m in f["test"]})
    result.update({
        "applied": True, "returncode": proc.returncode, "ran": int(ran.group(1)) if ran else None,
        "red": len(failures), "red_tests": [f["test"] for f in failures],
        "messages": [f["message"] for f in failures[:4]], "markers": markers, "marker_hits": marker_hits,
        "right_reason": bool(marker_hits), "seconds": round(time.monotonic() - started, 1),
    })
    return result


def main() -> None:
    out = Path(sys.argv[1])
    log_dir = Path(sys.argv[2])
    log_dir.mkdir(parents=True, exist_ok=True)
    wanted = set(sys.argv[3:])
    base = git("rev-parse", "--short", "HEAD").strip()
    results = json.loads(out.read_text(encoding="utf-8")) if out.exists() else []
    for mutation in MUTATIONS:
        if wanted and mutation[0] not in wanted:
            continue
        result = run_probe(mutation, log_dir)
        result["base_commit"] = base
        results = [r for r in results if r["id"] != result["id"]] + [result]
        out.write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8")
        print(f"{result['id']} rot={result.get('red')} grund={'ja' if result.get('right_reason') else 'NEIN'} "
              f"{result.get('error', '')} {result.get('seconds', '')}s", flush=True)


if __name__ == "__main__":
    main()
