# Mutationsproben

Ziel: Jede Sicherheitsprüfung muss von mindestens einem Test **aus dem richtigen Grund** abgesichert sein.
„0 rot“ wäre eine Lücke.

## Verfahren

- Werkzeug: `python/tools/mutationsproben.py` (committet in `e733811`, Marker-Ergänzungen in `9419f04`).
- Basis: Commit **`9419f04`** (Kern seither unverändert: `git diff --stat 9419f04 HEAD -- python/aria_router` leer).
- Je Probe: `git status --porcelain` muss leer sein → genau **eine** Textersetzung in genau einer Datei (der alte
  Text muss genau einmal vorkommen, sonst Abbruch) → komplette Suite
  `timeout 900 python3 -B -I -m unittest discover -s tests -t .` → rote Tests mit Meldung festhalten →
  `git checkout -- <datei>` → `git status --porcelain` wieder leer (vom Werkzeug geprüft).
- Ergebnis-JSON und Rohausgaben je Probe lagen außerhalb des Repos (Scratchpad `v3dev/mut-results.json`,
  `v3dev/mut-logs/<ID>.log`).
- „Richtiger Grund“: Das Werkzeug sucht je Probe festgelegte Marker (erwarteter Fehlercode oder Testname) in den
  Fehlerblöcken; zusätzlich habe ich jede Meldung gelesen (Bewertung unten).
- Suite je Lauf: [317] Tests (Stand vor `test_routing.py`); Gesamtdauer 604 s.

## Ergebnis: **46 von 46 Mutationen gefangen**

| ID | Prüfung | Ziel (Datei:Zeile) | Mutation | rot | rote Tests (Auszug) | Beispielmeldung |
|---|---|---|---|---|---|---|
| M01 | HMAC-Signaturprüfung der Clearance | `aria_router/authorization.py:196` | `if False:` | 5 | `test_digest_is_integrity_not_authorization`, `test_tampered_backend_field`, `test_tampered_expiry`, `test_tampered_privacy_field` (+1) | AssertionError: None != 'INVALID_SIGNATURE' |
| M02 | Ablaufzeit der Clearance | `aria_router/authorization.py:205` | `if False:` | 1 | `test_expired_clearance` | AssertionError: <ResultStatus.SUCCESS: 'success'> != <ResultStatus.BLOCKED: 'blocked'> |
| M03 | Einmal-Nonce (Replay-Schutz) | `aria_router/authorization.py:219` | `if False:` | 4 | `test_claude_delivery_report_counts`, `test_delivery_report`, `test_replay_rejected`, `test_replay_rejected_after_restart` | AssertionError: False is not true |
| M04 | Bindung an das Ziel-Backend | `aria_router/authorization.py:193` | `if False:` | 2 | `test_clearance_for_other_backend`, `test_wrong_backend` | AssertionError: 'INVALID_SIGNATURE' != 'BACKEND_MISMATCH' |
| M05 | Bindung an die job_id | `aria_router/authorization.py:207` | `if False:` | 2 | `test_job_mismatch`, `test_wrong_job` | AssertionError: <ResultStatus.SUCCESS: 'success'> != <ResultStatus.BLOCKED: 'blocked'> |
| M06 | Digest über den tatsächlich gesendeten Kontext | `aria_router/authorization.py:211` | `if False:` | 11 | `test_attachment_bytes_changed_after_clearance`, `test_attachment_metadata_changed_after_clearance`, `test_attachment_removed_after_clearance`, `test_claude_meta_changed_after_clearance` (+7) | AssertionError: <ResultStatus.SUCCESS: 'success'> != <ResultStatus.BLOCKED: 'blocked'> |
| M07 | Neuklassifikation im Adapter = Clearance-Klasse | `aria_router/authorization.py:213` | `if False:` | 1 | `test_privacy_mismatch` | AssertionError: <ResultStatus.SUCCESS: 'success'> != <ResultStatus.BLOCKED: 'blocked'> |
| M08 | Matrix-Nachprüfung im Adapter (Verifier) | `aria_router/authorization.py:215` | `if False:` | 1 | `test_adapter_rechecks_matrix` | AssertionError: <ResultStatus.SUCCESS: 'success'> != <ResultStatus.BLOCKED: 'blocked'> |
| M09 | Matrixprüfung beim Ausstellen (Issuer) | `aria_router/authorization.py:153` | `if False:` | 2 | `test_denied`, `test_issuer_refuses_unpermitted_privacy` | AssertionError: AuthorizationError not raised |
| M10 | Bindung an Absender und Chat | `aria_router/authorization.py:209` | `if False:` | 1 | `test_subject_mismatch` | AssertionError: <ResultStatus.SUCCESS: 'success'> != <ResultStatus.BLOCKED: 'blocked'> |
| M11 | Maximale Lebensdauer der Clearance | `aria_router/authorization.py:201` | `if False:` | 1 | `test_lifetime_too_long` | AssertionError: <ResultStatus.SUCCESS: 'success'> != <ResultStatus.BLOCKED: 'blocked'> |
| M12 | Clearance aus der Zukunft | `aria_router/authorization.py:203` | `if False:` | 1 | `test_not_yet_valid` | AssertionError: <ResultStatus.SUCCESS: 'success'> != <ResultStatus.BLOCKED: 'blocked'> |
| M13 | Fail-closed bei fehlendem Schlüssel | `aria_router/authorization.py:66` | `return b"\0" * 32` | 4 | `test_budget_released_when_not_executed`, `test_gateway_missing_key_fail_closed`, `test_key_symlink_rejected`, `test_missing_key_fail_closed` | AssertionError: 'COMPLETED' != 'KEY_UNAVAILABLE' |
| M14 | Schlüsseldatei nur für den Besitzer lesbar | `aria_router/authorization.py:73` | `if False:` | 1 | `test_key_permissions_too_open` | AssertionError: <ResultStatus.SUCCESS: 'success'> != <ResultStatus.BLOCKED: 'blocked'> |
| M15 | Absender-Allowlist | `aria_router/gateway.py:374` | `if False:` | 8 | `test_access_file_allowlist`, `test_access_file_and_static_list_intersect`, `test_access_file_dm_disabled`, `test_fake_identity_json_in_text` (+4) | AssertionError: <Handling.FORWARD_TO_CLAUDE_CHANNEL: 'forward_to_claude_channel'> != <Handling.DROP: 'drop'> |
| M16 | Privatchat: chat_id == sender_id | `aria_router/gateway.py:372` | `if False:` | 1 | `test_private_chat_sender_mismatch` | AssertionError: <Handling.REPLY: 'reply'> != <Handling.DROP: 'drop'> |
| M17 | Modusbefehle nur für authentifizierte Besitzer | `aria_router/gateway.py:542` | `if False:` | 1 | `test_mode_commands_only_for_authenticated_owner` | AssertionError: Tuples differ: (<Handling.REPLY: 'reply'>, 'MODE_SET') != (<Handling.BLOCKED: 'blocked'>, 'MOD |
| M18 | Selbstdeklarierte Felder an der Bridge abweisen | `aria_router/gateway.py:132` | `if False:` | 3 | `test_self_declared_fields_rejected_by_bridge`, `test_unknown_field_rejected_and_logged` | AttributeError: '_AssertRaisesContext' object has no attribute 'exception' |
| M19 | Datenschutzmatrix im Routing | `aria_router/core.py:155` | `if False:` | 36 | `test_access_file_allowlist`, `test_blocked_decision_logged_with_reason`, `test_checked_fallback_requires_claude_policy`, `test_checked_fallback_to_claude_when_enabled` (+27) | AssertionError: <Handling.BLOCKED: 'blocked'> != <Handling.FORWARD_TO_CLAUDE_CHANNEL: 'forward_to_claude_chann |
| M20 | Manuelle Wahl überstimmt keine Prüfung | `aria_router/core.py:199` | `chosen = [e for e in evaluations if e.backend ==` | 14 | `test_attachments_blocked`, `test_blocked_decision_logged_with_reason`, `test_budget_exhausted_blocks_manual_codex`, `test_checked_fallback_requires_claude_policy` (+10) | AssertionError: 'PRIVACY_CHANGED_BY_ENRICHMENT' != 'MANUAL_CHOICE_NOT_PERMITTED' |
| M21 | Fallback nur auf vollständig geprüftes Backend | `aria_router/core.py:208` | `alternatives = _rank([e for e in evaluations if ` | 2 | `test_checked_fallback_requires_claude_policy`, `test_checked_fallback_respects_claude_health` | AssertionError: Tuples differ: (<Handling.BLOCKED: 'blocked'>, 'PRIVACY_CHANGED_BY_ENRICHMENT') != (<Handling. |
| M22 | Secret-Muster -> CONFIDENTIAL | `aria_router/classify.py:102` | `self._confidential = PII_RULES + _compile(` | 13 | `test_attachment_text_content_scanned`, `test_secret_patterns`, `test_telegram_token_edge_forms` | AssertionError: <PrivacyTier.UNKNOWN: 'unknown'> != <PrivacyTier.CONFIDENTIAL: 'confidential'> |
| M23 | Vertrauliche Treffer schlagen die PUBLIC-Markierung | `aria_router/classify.py:112` | `confidential, internal = self._scan(text)` | 30 | `test_confidential_beats_marker`, `test_customer_keywords`, `test_extra_keywords`, `test_identifiers_with_underscores_and_digits` (+4) | AssertionError: <PrivacyTier.PUBLIC: 'public'> != <PrivacyTier.CONFIDENTIAL: 'confidential'> |
| M24 | Anhänge sind nie PUBLIC | `aria_router/classify.py:148` | `return (PrivacyTier.PUBLIC if tier is PrivacyTie` | 2 | `test_attachment_denied`, `test_attachments_never_public` | AssertionError: 'public' != 'unknown' |
| M25 | Memory-Ausschnitt einzeln gegen die Matrix | `aria_router/gateway.py:422` | `if False:` | 1 | `test_memory_filtered_per_snippet_for_codex` | AssertionError: Tuples differ: (<Handling.BLOCKED: 'blocked'>, 'codex') != (<Handling.REPLY: 'reply'>, 'codex' |
| M26 | Finale Neuklassifikation nach Anreicherung | `aria_router/gateway.py:611` | `if False:` | 1 | `test_final_reclassification_blocks` | AssertionError: Tuples differ: (<Handling.BLOCKED: 'blocked'>, 'PRIVACY_NOT_PERMITTED') != (<Handling.BLOCKED: |
| M27 | Tageslimit Jobs | `aria_router/budget.py:72` | `if False:` | 9 | `test_budget_exhausted_auto_routes_to_eligible_claude`, `test_budget_exhausted_blocks_manual_codex`, `test_concurrent_reservations_processes`, `test_concurrent_reservations_threads` (+5) | AssertionError: 'codex' != 'claude' |
| M28 | Dateisperre (Nebenläufigkeit Budget/Idempotenz/Nonce) | `aria_router/state.py:48` | `data = self._read()` | 5 | `test_concurrent_reservations_processes`, `test_concurrent_reservations_threads`, `test_parallel_budget_limit_holds`, `test_parallel_distinct_requests` (+1) | AssertionError: 16 != 5 |
| M29 | Circuit-Breaker im Routing | `aria_router/gateway.py:396` | `available = (settings.enabled and adapter is not` | 4 | `test_checked_fallback_respects_claude_health`, `test_circuit_recovers`, `test_claude_outage_without_unchecked_fallback`, `test_codex_outage_opens_circuit_and_reroutes` | AssertionError: <Handling.FORWARD_TO_CLAUDE_CHANNEL: 'forward_to_claude_channel'> != <Handling.BLOCKED: 'block |
| M30 | Nur Backend-Fehler zählen für den Breaker | `aria_router/health.py:65` | `if False:` | 1 | `test_non_health_codes_ignored` | AssertionError: True is not false |
| M31 | Idempotenz je (chat_id, message_id) | `aria_router/gateway.py:531` | `if False:` | 4 | `test_duplicate_detected_after_restart`, `test_duplicate_inbound_not_processed_twice`, `test_parallel_duplicate_requests_idempotent`, `test_sequential_duplicate_idempotent` | AssertionError: 'FORWARD_ISSUED' != 'DUPLICATE_MESSAGE' |
| M32 | Gruppen nur an Claude | `aria_router/gateway.py:395` | `restricted = False` | 1 | `test_group_message_claude_only` | AssertionError: Tuples differ: (<Handling.REPLY: 'reply'>, 'codex') != (<Handling.FORWARD_TO_CLAUDE_CHANNEL: ' |
| M33 | Shadow führt nie aus | `aria_router/gateway.py:617` | `if False:` | 12 | `test_codex_tie`, `test_execute_disabled_in_example_config`, `test_no_worker_called_or_network_used`, `test_roundtrip_shadow` (+5) | AssertionError: Tuples differ: (<Handling.REPLY: 'reply'>, 'codex', False) != (<Handling.SHADOW_DECISION: 'sha |
| M34 | Env-Allowlist, keine geerbte Umgebung | `aria_router/runner.py:225` | `cwd=str(workdir), env=None, shell=False` | 1 | `test_env_not_inherited` | AssertionError: 'TELEGRAM_BOT_TOKEN' unexpectedly found in {'AI_AGENT': 'claude-code_2-1-303_agent', 'ARIA_BRA |
| M35 | Verbotene Sandbox-Flags | `aria_router/runner.py:60` | `if False:` | 9 | `test_adapter_rechecks_template_before_run`, `test_adapter_refuses_forbidden_template`, `test_forbidden_flags_rejected`, `test_runners_refuse_forbidden_argv_at_run_time` | AssertionError: Tuples differ: (<ResultStatus.SUCCESS: 'success'>, None) != (<ResultStatus.BLOCKED: 'blocked'> |
| M36 | Ausgabelimit während des Laufs | `aria_router/runner.py:281` | `if False:` | 2 | `test_gateway_runner_output_limit_is_failure`, `test_output_limit_enforced_during_run` | AssertionError: Tuples differ: (<Handling.FAILED: 'failed'>, 'TIMEOUT') != (<Handling.FAILED: 'failed'>, 'OUTP |
| M37 | Prozessgruppe vollständig beenden | `aria_router/runner.py:301` | `pass` | 3 | `test_output_limit_enforced_during_run`, `test_process_group_killed`, `test_timeout_kills_process` | AssertionError: 5.0345487360609695 not less than 5 |
| M38 | Template-Prüfung unmittelbar vor dem Lauf | `aria_router/codex_adapter.py:115` | `pass` | 1 | `test_adapter_rechecks_template_before_run` | AssertionError: Tuples differ: (<ResultStatus.FAILED: 'failed'>, 'FORBIDDEN_SANDBOX_FLAG') != (<ResultStatus.B |
| M39 | Secret-Schwärzung in Antworten | `aria_router/result_check.py:41` | `count = 0` | 4 | `test_gateway_redacts_codex_output`, `test_multiple_secret_types`, `test_secret_redacted_and_marked`, `test_token_in_bot_url_redacted` | AssertionError: '[GESCHWÄRZT:secret.telegram_bot_token]' not found in 'Hier ist dein Token: 123456789:AAAAAAAA |
| M40 | Adapter-Basisklasse verifiziert vor jeder Ausführung | `aria_router/adapter_contract.py:174` | `verified = clearance if True else verifier.verif` | 33 | `test_adapter_rechecks_matrix`, `test_attachment_bytes_changed_after_clearance`, `test_attachment_metadata_changed_after_clearance`, `test_attachment_removed_after_clearance` (+29) | AssertionError: <ResultStatus.SUCCESS: 'success'> != <ResultStatus.BLOCKED: 'blocked'> |
| M41 | execute() nicht überschreibbar | `aria_router/adapter_contract.py:125` | `if False:` | 3 | `test_subclass_cannot_override_execute`, `test_subclass_cannot_override_init_subclass`, `test_subclass_cannot_override_outbound_digest` | AssertionError: TypeError not raised |
| M42 | Echte Codex-Ausführung standardmäßig aus | `aria_router/gateway.py:253` | `if True:` | 22 | `test_access_file_unreadable_fail_closed`, `test_budget_exhausted_blocks_manual_codex`, `test_claude_outage_without_unchecked_fallback`, `test_default_matrix_codex_only_public` (+18) | AttributeError: 'SubprocessRunner' object has no attribute 'calls' |
| M43 | Echte Ausführung nur mit Isolations-Wrapper | `aria_router/config.py:343` | `if False:` | 1 | `test_real_execution_requires_isolation_wrapper` | AssertionError: ConfigurationError not raised |
| M44 | Kein Klartext im Entscheidungsprotokoll | `aria_router/decision_log.py:34` | `if False:` | 6 | `test_forbidden_keys_rejected` | AssertionError: DecisionLogError not raised |
| M46 | Telegram-Token auch mit '-' am Ende und in der Bot-URL | `aria_router/classify.py:28` | `re.compile(r"\b\d{8,10}:[A-Za-z0-9_-]{35}\b")` | 4 | `test_telegram_token_edge_forms`, `test_token_in_bot_url_redacted` | AssertionError: <PrivacyTier.PUBLIC: 'public'> != <PrivacyTier.CONFIDENTIAL: 'confidential'> |
| M45 | Keine Ausführung ohne Protokolleintrag | `aria_router/gateway.py:667` | `pass` | 8 | `test_budget_exhausted_auto_routes_to_eligible_claude`, `test_checked_fallback_to_claude_when_enabled`, `test_execute_writes_decision_and_outcome`, `test_group_message_claude_only` (+4) | IndexError: list index out of range |

## Bewertung der Gründe

- **Direkte Treffer** (Test erwartet genau den entfernten Fehlercode bzw. die verhinderte Wirkung): M01-M03,
  M05-M18, M22-M24, M27-M41, M43-M46.
- **Treffer über die nächste Verteidigungslinie** (Schutz hält weiter, aber der Test erkennt die fehlende Prüfung am
  falschen Code; gewollt, damit jede Prüfung einzeln abgesichert ist):
  - M04: ohne Backend-Vergleich scheitert die Signatur, weil der Schlüssel je Backend abgeleitet ist
    (`'INVALID_SIGNATURE' != 'BACKEND_MISMATCH'`).
  - M19, M20: ohne Matrix im Routing verweigern Issuer bzw. finale Neuklassifikation; zusätzlich werden die reinen
    Routingtests (`test_privacy_blocks_manual_codex`, `test_privacy_overrides_manual`) direkt rot.
  - M21: beim Datenschutzfall greift die finale Neuklassifikation; beim Verfügbarkeitsfall
    (`test_checked_fallback_respects_claude_health`) erreichte der **ungeprüfte Fallback tatsächlich Claude** und
    nur der Test fängt ihn.
  - M25: ohne Einzelprüfung der Memory-Ausschnitte landet der vertrauliche Ausschnitt im Kontext, die finale
    Neuklassifikation blockiert dann den ganzen Auftrag (`BLOCKED` statt gefilterter `REPLY`): kein Leck, aber
    Funktionsverlust; der Test erkennt es.
  - M26: ohne finale Neuklassifikation verweigert der Issuer (`PRIVACY_NOT_PERMITTED`).
- **Mit Begleitfehlern**: M42 (22 rot) – erwarteter Treffer `test_default_runner_is_mock`, die übrigen scheitern,
  weil `SubprocessRunner` kein `calls` hat (Folge der Mutation). M45 – `IndexError` in Tests, die den
  Entscheidungsdatensatz vor der Ausführung suchen: Er fehlt (genau die Mutation). M18 – zusätzlich zur Meldung
  `RequestError not raised` ein `AttributeError` im subTest (Folgefehler).
- **M37** (Prozessgruppe nicht beenden): `Enkelprozess … läuft noch` und Zeitüberschreitungen, weil der
  Hauptprozess nach Limit/Timeout weiterläuft.

## Vorlauf und Befund

Erster Durchgang (Basis `e733811`, 45 Proben): 45 von 45 rot. M16 meldete zunächst „Grund NEIN“, weil der Marker
nur die zweite Assertion traf; die Meldung zeigte den richtigen Grund (`REPLY != DROP`: Anfrage mit fremder
`chat_id` wurde ausgeführt), Marker ergänzt. Im M28-Lauf schlug zusätzlich `test_secret_patterns` fehl, **unabhängig
von der Mutation**: Ein zufällig erzeugtes Telegram-Token endete auf `-` und wurde nicht erkannt. Das war ein echter
Fehler im Klassifikator (auch in der Bot-API-URL-Form), behoben in `9419f04`, abgesichert durch M46. Danach wurden
alle 46 Proben auf der neuen Basis wiederholt (Tabelle oben); `test_privacy` lief 30-mal ohne Fehlschlag.

## Nachtrag lane-codex: Isolationskäfig (M47–M55)

Basis **`dbb993a`** (Worktree vor jeder Probe sauber, danach `git checkout --`). Werkzeug unverändert
(`python/tools/mutationsproben.py`). Suite je Lauf: **380 Tests**. Ergebnis-JSON + Logs außerhalb des Repos
(`scratchpad/lane-codex/mut-results.json`, `mut-logs/<ID>.log`).

### Ergebnis: **9 von 9 Mutationen gefangen** (jede am richtigen Grund, Marker = roter Testname)

| ID | Prüfung | Ziel (Datei) | Mutation | rot | roter Test |
|---|---|---|---|---|---|
| M47 | memfd_create-Sperre (M2) | `isolation/iso_exec.py` | `memfd_create` aus DENY_ALWAYS entfernt | 1 | `test_memfd_create_denied` |
| M48 | execveat(AT_EMPTY_PATH)-Sperre (M2) | `isolation/iso_exec.py` | seccomp-Regel → `pass` | 1 | `test_execveat_empty_path_denied` |
| M49 | RLIMIT_AS im Wrapper (M3) | `isolation/iso_exec.py` | `if "AS" in lim` → `if False` | 1 | `test_rlimit_as_set_in_wrapper` |
| M50 | RLIMIT_CPU im Wrapper (M3) | `isolation/iso_exec.py` | `if "CPU" in lim` → `if False` | 1 | `test_rlimit_cpu_set_in_wrapper` |
| M51 | Landlock-ABI-Check fail-closed (N3) | `isolation/iso_exec.py` | `if abi < min_abi` → `if False` | 1 | `test_abi_requirement_fail_closed` |
| M52 | Symlink-Konfigpfad-Ablehnung (N4) | `isolation/iso_exec.py` | `if stat.S_ISLNK` → `if False` | 1 | `test_symlink_config_path_rejected` |
| M53 | auth-Kopie read-only Default (M1) | `isolation/profiles.py` | Default-Return `file_ro`→`file_rw` | 2 | `test_auth_default_readonly_vs_writable` (+1) |
| M54 | last.txt-Quercheck (M3) | `codex_adapter.py` | `if self._require_output_file` → `if False` | 2 | `test_fail_when_output_file_missing`, `..._mismatches_jsonl` |
| M55 | Scope-Pflicht/NO_USER_BUS (M3) | `isolation/caged_runner.py` | `if not self.bus_reachable()` → `if False` | 1 | `test_no_user_bus_is_fail_closed` |

Bewertung: alle direkte Treffer (der entfernte Schutz wird genau vom genannten Test erkannt). M53/M54 machen
je 2 Tests rot (die read-only- und die writable-Assertion bzw. die fehlende und die abweichende last.txt).


## Nachtrag B+-Fix (M56–M60)

Basis **`87664e4`** (main, sauber), je Probe volle Suite (393 Tests), danach `git checkout --`.

| ID | Prüfung | Ziel | Mutation | rot | roter Test |
|---|---|---|---|---|---|
| M56 | Adapter reicht Profil an Parser | `codex_adapter.py` | `allow_tools=self._allow_bypass` → `False` | 1 | `test_tools_profile_accepts_tool_events` |
| M57 | Parser beachtet `allow_tools` | `hardened_codex.py` | `if tool_seen and not allow_tools` → `if tool_seen` | 3 | `test_tool_events_allowed_and_counted_with_allow_tools`, `test_allow_tools_keeps_failure_rules`, `test_tools_profile_accepts_tool_events` |
| M58 | text-Profil lehnt Werkzeuge weiter ab | `hardened_codex.py` | Bedingung → `if False` | 3 | `test_tool_event_rejected`, `test_text_profile_rejects_tool_events`, `test_tool_event` |
| M59 | Config verbietet `address_space_bytes` bei tools | `config.py` | Prüfung → `if False` | 1 | `test_tools_with_address_space_limit_rejected` |
| M60 | Runner verbietet RLIMIT_AS bei tools | `isolation/caged_runner.py` | Prüfung → `if False` | 1 | `test_tools_profile_rejects_address_space_limit` |

5 von 5 rot, jeweils am erwarteten Test.

---

# TypeScript: Plugin-Patch (Phase E)

## Verfahren

- Werkzeug: `telegram/plugin/test/mutationsproben.py` (committet in `aa75a6a`, ergänzt in `8484b7f`).
- Basis: Commit **`847daa9`** (`aria-gateway.ts`, `server.ts` seitdem unverändert, siehe `TESTERGEBNISSE.md`).
- Je Probe: `git status --porcelain -uall` leer → genau **eine** Ersetzung (Suchtext genau einmal vorhanden, sonst
  Abbruch) → `run-tests.sh` mit den zugeordneten Testdateien (Logs behalten) → rote Tests aus den Logs →
  `git checkout -- <datei>` → Worktree wieder leer **und** Blob-Hash gleich dem Stand vor der Probe (vom Werkzeug geprüft).
- „Richtiger Grund“: Je Probe sind Testbezeichnungen festgelegt, die rot sein **müssen** (`expected_all_red`);
  zusätzlich wird geprüft, dass jede Testdatei geladen hat (kein Ladefehler als Schein-Rot).
- Ergebnis-JSON außerhalb des Repos: `<scratchpad>/lane-telegram/mutation-final.json`. Laufzeit 21:10:42–21:15:16 UTC (268 s).

## Ergebnis: **17 von 18 Mutationen gefangen, 1 überlebt (Befund)**

| ID | Prüfung | Ziel (Datei:Zeile, Basis `847daa9`) | Mutation | rot | erwartete rote Tests (Auszug) |
|---|---|---|---|---|---|
| T01 | sender_id nur aus `ctx.from.id` | `aria-gateway.ts:176` | `from.id` → `message.forward_origin?.sender_user?.id ?? from.id` | 3 | `Modus active/shadow > gefälschte sender_id …`, `gefälschte IDs in Text, Caption, Weiterleitung …` |
| T02 | sender_id nicht aus dem Text | `aria-gateway.ts:176` | ID per Regex aus dem Text | 3 | wie T01 |
| T03 | Blocked nie weiterleiten | `aria-gateway.ts:558` | `forward: false` → `true` | 8 | `Modus active > Blocked …`, `reply/blocked/failed/drop/not_intercepted: unter BEIDEN …`, `verweigerte Freigaben kippen nie …`, E2E `Modusbefehle …` |
| T04 | Verweigerung aus ungültiger Antwort respektieren | `aria-gateway.ts:547` | Prüfung auf `rawHandling`/`rawPlanned` aus | 2 | `Verweigerung mit beigelegter Weiterleitung …`, `active: Zusatzfälle > verweigerte Freigaben …` |
| T05 | Modus-Allowlist: Gateway erst nach `gate()` | `server.ts:907` | Modusbefehle vor `gate()` an das Gateway | 6 | `Modus active/shadow > Modusbefehl von Fremdem …`, E2E `Fremder erreicht das Python-Gateway nie` |
| T06 | Fehler-Default `claude_status_quo` | `aria-gateway.ts:88` | Default `block` | 9 | `Ausfallregel: Default claude_status_quo …`, `Modus active > Router-Timeout`, E2E `Gateway-Fehler …` |
| T07 | Ausfallregel `block` wird beachtet | `aria-gateway.ts:578` | Ausfall leitet immer weiter | 9 | `Router-Ausfall: claude_status_quo leitet weiter, block nicht`, `Modus active > Router-Absturz` |
| T08 | Ein Poller: kein `bot.start()` im Gateway | `aria-gateway.ts:755` | `void input.bot.start?.({})` eingefügt | 2 | `genau ein Bot-Objekt, ein bot.start() …` (Laufzeit), `aria-gateway.ts: nur erlaubte Importe … kein Polling …` (statisch) |
| T09 | Ein Poller: kein `getUpdates` im Gateway | `aria-gateway.ts:755` | `bot.api.raw.getUpdates(…)` eingefügt | 2 | wie T08 |
| T10 | Patch fügt keine Token-/PID-Logik hinzu | `server.ts:964` | `writeFileSync(PID_FILE, …)` eingefügt | 5 | `hinzugefügte Patch-Zeilen enthalten keine …`, `server.ts hat dieselbe Anzahl Poller-relevanter Stellen …` |
| T11 | Dedup je `(chat_id, message_id)` | `aria-gateway.ts:710` | Prüfung aus | 2 | `Modus active > doppelte message_id`, `active: doppelte message_id … (auch bei Router-Ausfall)` |
| T12 | Payload-Bindung (content) | `aria-gateway.ts:591` | Zeile entfernt | 2 | `Weiterleitung mit falscher Bindung ist ein Router-Ausfall`, `Modus active > ungültige Bridge-Antwort …` |
| T13 | Shadow wartet nie auf die Bridge | `aria-gateway.ts:757` | Shadow-Ergebnis abwarten | 3 | `Modus shadow > Router-Timeout`, `shadow: liefert sofort true …`, `off/shadow: Permission-Antwort sofort …` |
| T14 | Timeout tötet die Prozessgruppe | `aria-gateway.ts:445` | `killGroup()` entfernt | 1 | `Timeout: Prozessgruppe wird getötet …` |
| T15 | Kind-Umgebung nur `PATH` | `aria-gateway.ts:453` | `...process.env` vererbt | 2 | `Kindprozess: nur PATH in der Umgebung …` (Token-Kanarienvogel), statisch `env: { PATH … }` |
| T16 | stdin-Fehler nicht fatal | `aria-gateway.ts:479` | stdin-Fehler wieder fatal | **0** | – (siehe Befund) |
| T17 | Supabase-Log unabhängig von der Weiterleitung | `server.ts:998` | `if (ariaForward && SB_URL && SB_KEY)` | 5 | `der Supabase-Log-Block ist unverändert …`, `Modus active > Codex-Route` |
| T18 | Schemaprüfung `handling` | `aria-gateway.ts:338` | Prüfung entfernt | 2 | `jede Abweichung vom Schema wird abgelehnt` |

Von der Vorgabe geforderte Klassen: sender_id-Quelle (T01, T02), Blocked-nicht-weiterleiten (T03, T04),
Modus-Allowlist (T05), Fehler-Default (T06, T07), Single-Poller-Test (T08, T09, T10), Dedup (T11).

## Befunde

- **T16 überlebt.** Der Test `Bridge antwortet ohne stdin zu lesen: die gültige Antwort zählt` prüft das Ergebnis,
  übt den Fehlerpfad aber nicht aus: bun 1.3.13 meldet auf `child.stdin` bei einem nicht lesenden Kindprozess kein
  `error` (Probe mit 1 kB, 300 kB, 3 MB: nur `finish`). Die Behandlung bleibt als Absicherung für Laufzeiten, die
  EPIPE melden (ohne Handler würde ein unbehandeltes `error`-Ereignis werfen); unter bun ist sie nicht beobachtbar.
  Testname und Kommentar sagen das jetzt ausdrücklich.
- **Folgefehler durch geteilten Zustand (behoben).** Im ersten Durchgang (Basis `aa75a6a`) war T03 mit 11 roten Tests
  rot, davon 3 Folgefehler: Ein früher fehlgeschlagener E2E-Test ließ den Chat im Codex-Modus, spätere Tests erbten
  ihn. Behoben in `8484b7f` (Moduszustand vor jedem E2E-Test zurücksetzen); danach 8 rote Tests, alle mit Sperrbezug.
- **Probenschwäche vor dem Lauf erkannt.** Die statische Prüfung las die hinzugefügten Zeilen zunächst aus der
  committeten `.patch`-Datei statt aus einem frischen Diff; T10 wäre dort unsichtbar gewesen. Außerdem übersah das
  Muster `\.start\s*\(` optionale Aufrufe (`.start?.()`). Beides vor dem Probelauf korrigiert (`aa75a6a`).
- Bei T14 bleibt der Mock-Prozess nach der Probe bis zu 10 s stehen (er wird dann gerade nicht getötet); kein
  Restprozess nach dem Lauf (`ps` geprüft).

## Re-Review-Fixes (Astra + Opus, 24.10.2026): M61–M75 (Python) + MT1–MT3 (TypeScript)

Basis: Commit **`a5d04a4`** (Branch `lane-fix`). Verfahren wie oben; Python-Proben über `mutationsproben.py`
(volle Suite je Probe, `right_reason` = erwarteter Testname erscheint im Fehlerblock), TS-Proben manuell
(`sed` auf die committete Datei → genau eine Test-Datei via `run-tests.sh` → `git checkout --`).
Ergebnis-JSON/Logs: `scratchpad/lane-fix-probes/mut-results.json`, `mut-logs/<ID>.log`.

**Ergebnis: 18 von 18 Mutationen gefangen (alle aus dem richtigen Grund).**

| ID | Prüfung (Befund) | Ziel | Mutation | rot | gefangen von |
|---|---|---|---|---|---|
| M61 | erweiterte Credential-Namen (#4) | `profiles.py:_is_secret_name` | `if name in SECRET_NAMES:` → `if False:` | 1 | `test_extended_credential_names_blocked` |
| M62 | Symlink-Hard-Block am Ziel (#5) | `profiles.py:hard_credential_blocklist` | realpath-`add` → `pass` | 1 | `test_symlinked_hard_block_covers_target` |
| M63 | exec-root carvet Secrets (#2) | `profiles.py:tools_profile` | `build_exec_rx(…, hard_block)` → `frozenset(claude)` | 1 | `test_exec_root_does_not_regrant_secret` |
| M64 | state_dir/Repo gesperrt (#6) | `profiles.py:tools_profile` | self_block-`if` → `if False` | 2 | `test_state_dir_and_repo_blocked_workspace_rw` |
| M65 | Startschutz read-only (H1) | `profiles.py:tools_profile` | ro-Ziele → `(set(), set())` | 2 | `test_protect_startup_true_makes_startup_read_only` |
| M66 | strukturelle Käfig-Prüfung (#1) | `caged_runner.py:wrapper_is_tools_cage` | iso_exec-Basename-Check → `if False` | 1 | `test_wrapper_structural_rejects_bypass` |
| M67 | Scope-Stop vor last.txt (#7) | `caged_runner.py:run` | stop/read-Reihenfolge getauscht | 1 | `test_scope_stopped_before_read` |
| M68 | fcntl F_SETOWN/SETSIG (#8b) | `iso_exec.py:seccomp_prepare` | `for cmd in (…)` → `for cmd in ()` | 2 | `test_fcntl_setown_foreign_denied`, `test_fcntl_setsig_denied` |
| M69 | prlimit64 nur pid==0 (#8c) | `iso_exec.py:seccomp_prepare` | prlimit64-Regel entfernt | 1 | `test_prlimit_foreign_denied` |
| M70 | process_madvise gesperrt (#8) | `iso_exec.py:DENY_ALWAYS` | aus der Liste entfernt | 1 | `test_process_madvise_denied` |
| M71 | STDIN_FAILED bei EPIPE (#13) | `runner.py:_supervise` | `error=STDIN_FAILED;break` → `pending=pending[:0]` | 1 | `test_stdin_write_failure_is_error` |
| M72 | exakter last.txt-Quercheck (#14) | `codex_adapter.py:_transmit` | Teilstring-Akzeptanz wieder eingebaut | 1 | `test_substring_last_txt_rejected` |
| M73 | Anhänge fail-closed (#15) | `caged_runner.py:run` | `if spec.files:` → `if False:` | 1 | `test_files_fail_closed` |
| M74 | Memory-Quellname klassifiziert (#16) | `classify.py:_classify_memory` | `most_sensitive(text,source)` → `text_tier` | 1 | `test_memory_source_label_classified` |
| M75 | SIGTERM stoppt den Scope (#9) | `caged_runner.py:_raise_terminated` | Handler → no-op (`return`) | 1 | `test_sigterm_stops_scope_and_grandchild` |
| MT1 | Einmal-Modell-Bindung (#11) | `aria-gateway.ts:decideActive` | freigegebene `params` → `null` | 1 | `modes.test.ts` Einmal-Modus (`'bitte prüfen'`) |
| MT2 | UTF-8 einmal dekodieren (#12) | `aria-gateway.ts:runBridgeProcess` | `Buffer.concat(…)` → pro-Chunk `.join('')` | 1 | `gateway-unit.test.ts` `#12` (Umlaut) |
| MT3 | SIGTERM-first-Teardown (#9) | `aria-gateway.ts:fail` | `killGroup('SIGTERM')` → `'SIGKILL'` | 1 | `gateway-unit.test.ts` `#9` (`[trap-term]`) |

Anmerkungen:
- **#7/#8a sind mehrfach abgesichert (Defense-in-depth).** `_read_last` hat drei redundante Riegel
  (O_NOFOLLOW, fstat S_ISREG, realpath-unter-Workspace); eine Einzelmutation lässt die Symlink-/FIFO-Tests noch
  grün, weil ein anderer Riegel greift — die materielle Einzel-Prüfung ist die Reihenfolge (M67). Für #8a gibt es
  keinen Fix (socketpair bleibt erlaubt), daher keine Mutation; belegt ist die bestehende `socket(AF_UNIX)`-Sperre.
- Alle Proben ließen den Worktree sauber zurück (`git status --porcelain` leer; vom Werkzeug geprüft).

## Re-Review 2: verzögerte Ausführung (Opus, 25.10.2026): M76–M84

Basis **`3df65fc`** (Branch `lane-fix2`, Werkzeug unverändert). Volle Suite je Probe (**441 Tests**),
`git status --porcelain` vor/nach leer (vom Werkzeug geprüft). Ergebnis-JSON/Logs außerhalb des Repos:
`scratchpad/lane-fix2/mut-results.json`, `mut-logs/<ID>.log`.

**Ergebnis: 9 von 9 Mutationen gefangen (alle aus dem richtigen Grund).**

| ID | Prüfung (Punkt) | Ziel (`profiles.py`) | Mutation | rot | gefangen von |
|---|---|---|---|---|---|
| M76 | `.claude.json` hart gesperrt (1b) | `HARD_CREDENTIAL_FILES` | `.claude.json` aus der Liste entfernt | 2 | `test_claude_json_hard_blocked`, `test_claude_json_read_denied` (Käfig) |
| M77 | Top-Level-Dotfiles ro (1a) | `top_level_startup_files` | `out.add(...)` → `pass` (Sweep liefert nichts) | 1 | `test_top_level_dotfiles_read_only` |
| M78 | Secret-Dotfiles bleiben vom Sweep ausgeschlossen (1a) | `top_level_startup_files` | **beide** Ausschluss-Checks (`_is_secret_name` UND `in hard_block`) → `if False` | 4 | `test_secret_top_level_dotfile_stays_hard_blocked`, `test_credentials_blocked`, `test_tools_blocks_credentials_and_claude` |
| M79 | Paketmanager/`.local`/`state` ro (1c) | `STARTUP_RO_DIRS` | neue Einträge entfernt (nur `.config`/`scripts`/`brain`) | 2 | `test_package_dirs_read_only_but_registry_writable`, `test_deferred_exec_ro_targets_denied_rw_ok` (Käfig) |
| M80 | gesourcte Dateien ro (1d) | `parse_sourced_paths` | Treffer-Bedingung → `if False` (nichts gesammelt) | 1 | `test_sourced_files_read_only` |
| M81 | Cron-Ziele ro (1e) | `resolve_cron_targets` | `.add(ap)` → `pass` | 1 | `test_cron_targets_read_only` |
| M82 | Cron-Lesefehler → Warnung (1e) | `read_user_crontab` | Fehler-Return `([], warn)` → `([], None)` | 1 | `test_cron_read_failure_yields_warning_not_crash` |
| M83 | Geschwister-Worktrees/Familie gesperrt (1g) | `sibling_worktrees` | `if is_worktree or is_family:` → `if False:` | 1 | `test_sibling_worktrees_and_family_blocked` |
| M84 | totes `STARTUP_RO_ABS` entfernt (1f) | Modul-Konstante | `STARTUP_RO_ABS = ()` → `("/home/aria/aria",)` | 1 | `test_startup_ro_abs_has_no_dead_entry` |

Befunde:
- **M78 war zuerst 0 rot** (Einzel-Mutation nur eines Checks). Das belegt Defense-in-depth: der Secret-Ausschluss
  des Top-Level-Sweeps hat zwei unabhängige Riegel (`_is_secret_name` ODER `in hard_block`); eine Einzel-Mutation
  lässt den anderen greifen. Die materielle Eigenschaft („Secrets nie im Sweep → nie ro lesbar") wird erst durch
  das Entfernen **beider** Checks weggenommen → dann 4 rot (inkl. Käfig-Test `test_credentials_blocked`, der
  `/home/aria/.env` real liest). „0 rot" wurde als Befund behandelt und die Mutation entsprechend geschärft.
- **1a (latente Datei) hat keine Einzelzeilen-Mutation.** Dass `home_root` keine rw-/MAKE_REG-Regel bekommt, ist
  eine strukturelle Eigenschaft von `_descend` (kein pauschaler Home-rw-Vorfahr), kein einzelner Guard. Abgedeckt
  durch `test_home_root_not_writable_latent_dotfile_uncreatable` und den Käfig-Test `create-denied`.
- Alle Proben ließen den Worktree sauber zurück.

## E44 Option 2: Schreib-Allowlist (Kais, TG 12923, 25.10.2026): M85–M100, Kombi M65K/M77K, Neumessung

Basis **`33f28ee`** (Branch `e44-write-allowlist`). Werkzeug erweitert um Kombi-Proben (alter/neuer Text als Tupel,
jede Stelle genau einmal vorhanden) und den reparierten M65-Ausschnitt (passte seit `51d99a5` nicht mehr,
`SNIPPET_COUNT=0`). Volle Suite je Probe (**481 Tests**), `git status --porcelain` vor/nach leer (vom Werkzeug
geprüft). Ergebnis-JSON/Logs außerhalb des Repos: `scratchpad/v3-e44/mut-results.json`, `mut-logs/<ID>.log`.
Laufzeit 12:55:36–13:13:32 UTC. „rot“ = Fehlerblöcke laut Werkzeug (subTests zählen einzeln).

**Ergebnis neue Proben: 16 von 16 rot, alle aus dem richtigen Grund.**

| ID | Prüfung | Ziel | Mutation | rot | gefangen von (Auszug) |
|---|---|---|---|---|---|
| M85 | E44-Kern: Home außerhalb der Allowlist nicht schreibbar | `profiles.py:tools_profile` | `rw_dirs, rw_files = [], []` → Abstieg über das ganze Home (Zustand vor E44) | 20 | `test_without_allowlist_home_is_read_only`, `test_kostenwache_class_closed`, `test_home_readable_but_not_writable`, Käfig `test_write_only_in_workspace_and_allowlist` (+12 Tests) |
| M86 | ro-Ziele (Hooks, Cron) bleiben im Allowlist-Ordner ro | `tools_profile`, Allowlist-Abstieg | `hard_block \| ro_block` → `hard_block` | 5 | `test_allowlist_carves_secret_hooks_and_cron_targets`, `test_allowlist_dir_keeps_startup_carveouts` (`'rw' not found in ('ro', 'file_ro') : projekte/x/.git/hooks/pre-commit`), Käfig (+2) |
| M87 | Secrets bleiben im Allowlist-Ordner hart gesperrt | ebd. | → `frozenset(ro_block)` | 4 | `test_allowlist_carves_secret_hooks_and_cron_targets`, `test_protect_startup_off_opens_hooks_only_inside_allowlist`, Käfig (`read-denied:…/.env LEAK (lesbar)`) (+1) |
| M88 | keine versteckte Komponente | `validate_write_allowlist` | Prüfung → `if False` | 3 | `test_rejects_hidden_components`, `test_invalid_entry_fails_closed_with_clear_error`, `test_runner_validates_at_construction` |
| M89 | nicht in gesperrten/Startschutz-Bereichen | ebd. | `inside blocked area` → `if False` | 8 | `test_rejects_startup_protected_dirs`, `test_rejects_state_dir_repo_and_family`, `test_rejects_credential_symlink_target` (+5 Tests) |
| M90 | nicht in ro-Zielen des Profilbaus (Cron, Hooks) | ebd. | `inside read-only protected area` → `if False` | 2 | `test_rejects_dynamic_blocked_and_read_only`, `test_build_time_rejects_entry_inside_cron_dir` |
| M91 | kein Symlink im Pfad | ebd. | realpath-Prüfung → `if False` | 3 | `test_symlinked_ancestor_cannot_smuggle_protected_area`, `test_rejects_missing_file_and_symlinks`, `test_runner_validates_at_construction` |
| M92 | echt unter `home_root` | ebd. | → `if False` | 2 | `test_rejects_home_root_and_outside` (`'below home_root' not found in '… /etc: hidden path component …'`), Konfig-Test |
| M93 | keine Überlappung mit `workspace_root` | ebd. | → `if False` | 3 | `test_rejects_overlap_with_run_container`, `test_overlap_with_workspace_root_rejected`, `test_build_time_rejects_overlap_with_runs_root` |
| M94 | kanonischer Pfad | ebd. | → `if False` | 2 | `test_rejects_relative_and_non_canonical` (`'canonical' not found in '… projekte/app/: path contains a symlink'`), Konfig-Test |
| M95 | Neuprüfung je Lauf beim Profilbau | `tools_profile` | `validate_write_allowlist(…)` → `tuple(write_allowlist)` | 4 | `test_build_time_rejects_entry_inside_cron_dir`, `test_entry_swapped_for_symlink_after_construction_fails_closed`, `test_build_time_rejects_overlap_with_runs_root` (+1) |
| M96 | Laufzeit-Fehlercode `WRITE_ALLOWLIST_INVALID` | `caged_runner.py:run` | `except P.WriteAllowlistError` → `except ()` | 1 | `test_entry_swapped_for_symlink_after_construction_fails_closed` (dann `PROFILE_BUILD_FAILED`) |
| M97 | Runner prüft schon beim Bau | `CagedCodexRunner.__init__` | Prüfung → `tuple(write_allowlist)` | 4 | `test_runner_validates_at_construction` (4 subTests) |
| M98 | Konfiguration prüft fail-closed | `config.py:_parse_codex` | Prüfung → `tuple(allow_raw)` | 8 | `test_invalid_entry_fails_closed_with_clear_error`, `test_inside_state_dir_rejected`, `test_overlap_with_workspace_root_rejected` |
| M99 | Allowlist nur im tools-Profil (Konfiguration) | `config.py` | → `if False` | 1 | `test_text_profile_rejects_allowlist` |
| M100 | Allowlist nur im tools-Profil (Runner) | `caged_runner.py` | → `if False` | 1 | `test_runner_text_profile_rejects_allowlist` |

**Neumessung der bestehenden Isolationsproben auf E44-Basis** (dieselbe Basis, dieselbe Suite):

| ID | Prüfung | rot | gefangen von | Einordnung |
|---|---|---|---|---|
| M53 | auth-Kopie read-only (M1) | 3 | `test_auth_default_readonly_vs_writable`, `test_text_profile_minimal`, `test_only_run_dirs_and_dev_null_writable_by_default` | unverändert wirksam |
| M61 | erweiterte Credential-Namen (#4) | 1 | `test_extended_credential_names_blocked` | unverändert (sonst ro lesbar statt rw) |
| M62 | Symlink-Hard-Block am Ziel (#5) | 2 | `test_symlinked_hard_block_covers_target`, `test_rejects_credential_symlink_target` | jetzt auch Allowlist-Sperre |
| M63 | exec-root carvet Secrets (#2) | 1 | `test_exec_root_does_not_regrant_secret` | unverändert |
| M64 | `state_dir`/Repo gesperrt (#6) | 2 | `test_state_dir_and_repo_blocked_workspace_rw`, `test_build_cfg_threads_state_repo_and_startup` | unverändert (sonst lesbar) |
| M65 | Startschutz ro (H1), Ausschnitt repariert | **0** | — | **erwartet: redundant seit E44** (Home ohnehin nicht rw; Allowlist-Sperre auf `STARTUP_RO_DIRS` läuft separat), siehe M65K |
| M65K | Kombi: M85 + M65 | 23 | u. a. `test_protect_startup_true_makes_startup_read_only` | die alte Schicht trägt wieder allein |
| M76 | `.claude.json` hart (1b) | 2 | `test_claude_json_hard_blocked`, Käfig `test_claude_json_read_denied` | unverändert (sonst ro lesbar) |
| M77 | Top-Level-Dotfiles ro (1a) | **0** | — | **erwartet: redundant seit E44**, siehe M77K |
| M77K | Kombi: M85 + M77 | 21 | u. a. `test_top_level_dotfiles_read_only` | die alte Schicht trägt wieder allein |
| M78 | Secrets nie im Dotfile-Sweep (1a) | 5 | `test_secret_top_level_dotfile_stays_hard_blocked`, Käfig `test_credentials_blocked`, `test_write_only_in_workspace_and_allowlist` (+2) | unverändert (sonst `.env` ro lesbar) |
| M79 | Paketmanager/`.local`/`state` ro (1c) | 1 | `test_rejects_startup_protected_dirs` | wirkt seit E44 über die Allowlist-Sperre (`state` wäre sonst freigebbar) |
| M80 | gesourcte Dateien (1d) | 1 | `test_sourced_files_read_only` | unverändert |
| M81 | Cron-Ziele (1e) | 3 | `test_build_time_rejects_entry_inside_cron_dir`, `test_allowlist_carves_secret_hooks_and_cron_targets`, Käfig (`append-denied:…/melde.sh LEAK`) | wirkt seit E44 im Allowlist-Ordner und als Sperre |
| M82 | Cron-Lesefehler → Warnung (1e) | 1 | `test_cron_read_failure_yields_warning_not_crash` | unverändert |
| M83 | Geschwister/Familie (1g) | 3 | `test_sibling_worktrees_and_family_blocked`, `test_siblings_not_blocked_when_startup_off`, `test_rejects_state_dir_repo_and_family` | jetzt auch Allowlist-Sperre |
| M84 | totes `STARTUP_RO_ABS` (1f) | 1 | `test_startup_ro_abs_has_no_dead_entry` | unverändert |

Befunde:
- **„0 rot“ bei M65/M77 ist ein Befund, kein Testloch:** beide Schichten sind seit E44 außerhalb der Allowlist
  deckungsgleich mit dem E44-Kern. Nachweis je Schicht: unter M85 allein (E44-Kern weg) bleiben
  `test_protect_startup_true_makes_startup_read_only` und `test_top_level_dotfiles_read_only` **grün** (der
  Startschutz hält), unter M65/M77 allein bleibt alles grün (der E44-Kern hält), erst die Kombination macht sie rot.
  Die Schichten bleiben im Code (Vorgabe: nicht entfernen, nur einordnen).
- **M92/M94 überlappen** mit anderen Regeln: Pfade außerhalb des Homes fängt zusätzlich die Punkt-Regel (`relpath`
  beginnt mit `..`), nicht kanonische Formen die realpath-Prüfung. Die Ablehnung bleibt, rot werden die Tests über
  die geprüfte Fehlermeldung (Anforderung „klarer Fehler“), wie bei M78 als Verteidigung in der Tiefe zu lesen.
- **M65-Ausschnitt** war seit `51d99a5` veraltet und wurde dort nicht neu gemessen; repariert. **M38**
  (`codex_adapter.py`) ist seit `d53e588` ebenso veraltet (`SNIPPET_COUNT=0`), außerhalb E44, nicht angefasst.
- Alle Proben ließen den Worktree sauber zurück.
