"""Kaefig-Profile fuer iso_exec.py.

Zwei Profile:
  text  (Default): Codex mit read-only-Sandbox; nur ephemere Arbeits-/CODEX_HOME-Baeume,
        kein Zugriff auf /home/aria, Netz nur TCP:443. Minimaler Baum (Codex ist statisch).
  tools ("B+", Owner-Entscheidung 24.10.): Codex mit abgeschalteter innerer Sandbox,
        darf GANZ /home/aria LESEN AUSSER Zugangsdaten, System (/usr,/etc) lesen+
        ausfuehren, aber `claude` NIE ausfuehren/lesen; Netz TCP/UDP frei; AF_UNIX gesperrt.
        SCHREIBEN (E44 Option 2, Kais TG 12923, 25.10.2026): nur in den ephemeren Lauf-
        Verzeichnissen (Workspace, Lauf-Home, CODEX_HOME, TMPDIR; plus /dev/null und - nur mit
        codex_auth_writable - die auth-KOPIE) und in ausdruecklich konfigurierten Projektordnern
        (codex.tools_write_allowlist, Default leer, validate_write_allowlist). Damit kann Codex
        keine Datei mehr anlegen/aendern, die spaeter ein ungekaefigter Prozess ausfuehrt, ausser
        in einem bewusst freigegebenen Ordner.

Landlock-Semantik (empirisch verifiziert, scratchpad/lane-codex): Rechte sind die UNION aller
Regeln auf dem Pfad UND seinen Vorfahren. Eine spezifischere Regel kann Rechte nur ADDIEREN,
nie entziehen. Daher wird jede Sperre (Secrets, claude, read-only-auth) per DESCENT gebaut:
der verbotene Pfad bekommt KEINE Regel und KEINEN Rechte-gewaehrenden Vorfahren.

M1 (auth read-only): das eine erlaubte Credential wird als KOPIE in ein read-only authdir gelegt
(file_ro), NICHT als Symlink auf ~/.codex (Review M1: Symlink loeste auf das echte Inode mit RW auf).
Im ephemeren CODEX_HOME liegt auth.json als Verweis (Symlink) auf die read-only-Kopie. Das echte
~/.codex ist NIE in der Allowlist. codex_auth_writable (Default False) macht nur die KOPIE schreibbar
(wird mit dem Lauf verworfen); das echte Login kann der Kaefig in keinem Modus zerstoeren.
"""
from __future__ import annotations

import os
import re
import stat
import subprocess
from dataclasses import dataclass, field

# System-Pfade, die beide Profile lesen (als realpath aufgeloest -> N4-konform, keine Symlinks in der Konfig).
SYSTEM_RO_DIRS = ("/etc/ssl",)
SYSTEM_RO_FILES = ("/etc/resolv.conf", "/etc/hosts", "/etc/localtime", "/dev/urandom")
TOOLS_SYSTEM_RO_DIRS = ("/etc",)            # tools darf /etc lesen (shadow etc. ist root-only -> ohnehin unlesbar)
TOOLS_EXEC_ROOTS = ("/usr", "/home/aria/.local", "/home/aria/.nvm", "/home/aria/.bun")

# beim Secret-Scan ausgelassene Verzeichnisnamen: Paket-/Build-/Cache-Baeume. Sie enthalten keine
# echten Nutzer-Zugangsdaten, nur Code/Artefakte mit secret-/token-aehnlichen Namen (z. B. ein
# Crate `secret.rs`, ein npm-Paket `*token*`). Wuerde man sie einzeln sperren, explodiert die
# Allowlist (gemessen: ~15k Einzelregeln) und Codex verliert Lesezugriff auf benoetigte Bibliotheken.
# Sie werden daher NICHT nach Secret-Namen durchsucht und als ganzer Baum rw gewaehrt.
# Echte Zugangsdaten liegen in Dotfiles/Config (.env*, *credentials*, runtime.env) ausserhalb dieser Baeume.
PRUNE_DIR_NAMES = frozenset({
    "node_modules", ".git", ".cache", ".cargo", ".rustup", ".npm", ".nvm", ".bun",
    "venv", ".venv", "target", "__pycache__", "site-packages", "dist-packages", ".pnpm-store",
})

# Credential-Dateierkennung (tools-Profil). Ziel sind ZUGANGSDATEN, nicht jede Datei mit
# "token"/"secret" im Namen. Gemessen: ein pauschales *token*/*secret*-Match ueber ganz /home/aria
# trifft ~190 Quell-/Daten-/Build-Dateien (maven-Daten, Video-Notizen, Rust-Deps, SQL-Migrationen
# namens *token*) und blaeht die Allowlist auf ~14k Regeln -> sperrt legitime Projektdateien und ist
# fragil. Darum gezielt: .env-Familie, *credential*, Key-Dateien, *-secrets-Dotfiles PLUS eine feste
# Liste bekannter Credential-Dateinamen (SECRET_NAMES) und id_*-Schluessel. Die echten Token-Speicher
# (.ssh/.claude/.codex/.config/gh/.gnupg/.aws/.kube/...) sind zusaetzlich HART gesperrt.
SECRET_EXT = (".pem", ".key", ".p12", ".pfx", ".keystore", ".jks", ".ovpn", ".kdbx")
# Bekannte Credential-/History-Dateinamen, die kein *credential*/*secret*/.env-Muster treffen.
SECRET_NAMES = frozenset({
    ".netrc", ".npmrc", ".pypirc", ".pgpass", ".my.cnf",
    ".git-credentials", ".bash_history", ".zsh_history", ".python_history",
})

# HART gesperrt (weder lesbar, noch schreib-, noch ausfuehrbar): Token-Speicher und Credential-Ordner.
# Relativ zum home_root. Nicht-existente Eintraege schaden nicht (sperren nur, falls spaeter angelegt).
HARD_CREDENTIAL_DIRS = (
    ".ssh", ".claude", ".codex", ".config/gh", ".config/rclone", ".gnupg", ".aws", ".kube",
)
# Credential-DATEIEN, die in sonst freigegebenen (oder vom Secret-Scan ausgelassenen) Baeumen liegen.
# .cargo ist PRUNE_DIR_NAMES -> der Scan findet credentials.toml dort nicht; darum hart gelistet (#3).
# .claude.json (Re-Review 2, 1b): Claude-Code-User-Konfig mit mcpServers (command = Ausfuehrung beim
# naechsten Start der mit bypassPermissions laufenden Hauptsession). Hart gesperrt, konsistent mit .claude/.
HARD_CREDENTIAL_FILES = (
    ".aria-chat.db", ".git-credentials", ".docker/config.json",
    ".cargo/credentials", ".cargo/credentials.toml", ".claude.json",
)
# Startschutz (Owner-Entscheidung TG 12861, Default an): read-only, damit ein injizierter Codex keine
# ungekaefigte Code-Ausfuehrung bei der naechsten Shell/Session/Git-Operation erreicht.
# Re-Review 2: der Top-Level-Dotfile-Sweep (top_level_startup_files) macht ohnehin JEDE regulaere
# Top-Level-Dotfile ro; diese Liste bleibt als explizite Absicht (login-/non-login-Startdateien).
# Einordnung E44 Option 2: ausserhalb der Schreib-Allowlist ist das Home ohnehin nicht mehr schreibbar;
# fuer diese Pfade ist der Startschutz dort also redundant (Verteidigung in der Tiefe, bewusst belassen).
# Wirksam bleibt er (a) INNERHALB von Allowlist-Ordnern: die ro-Ziele (z. B. <repo>/.git/hooks+config,
# Cron-Ziele, gesourcte Dateien) werden aus deren rw-Baum geschnitten, und (b) als Sperre: ein
# Allowlist-Eintrag in einem ro-/Startschutz-Bereich wird abgelehnt (validate_write_allowlist).
STARTUP_RO_FILES = (".bashrc", ".profile", ".bash_profile", ".bash_login", ".bash_logout", ".zshrc")
# Top-Level-Nicht-Dotfiles, die als ANWEISUNG an eine ungekaefigte Hauptsession gelesen werden (1a).
STARTUP_RO_TOPLEVEL = ("CLAUDE.md", "AGENTS.md")
# Verzeichnisse read-only (Re-Review 2, 1c). .local KOMPLETT (claude-Teile bleiben hart gesperrt; wo
# exec_roots EXECUTE gewaehrt, bleibt der Baum r-x ausfuehrbar, nur nicht schreibbar). Paketmanager
# (.nvm/.bun/.cargo/bin) und die cron-/Laufzeit-Zustaende (state) ebenfalls ro. .cargo/env als Datei ro
# (von .bashrc/.profile gesourct). Der uebrige .cargo-Baum (registry/git-Cache) war bis E44 rw; seit
# Option 2 ist er nur noch lesbar (wie jeder Nicht-Allowlist-Pfad: ein vergifteter Crate-Quelltext waere
# sonst beim naechsten ungekaefigten `cargo build` per build.rs ausgefuehrt worden).
STARTUP_RO_DIRS = (".config", "scripts", ".local", ".nvm", ".bun", ".cargo/bin", "brain", "state")
STARTUP_RO_FILESET = (".cargo/env",)
STARTUP_RO_ABS = ()            # Re-Review 2, 1f: totes /home/aria/aria entfernt. Brain = STARTUP_RO_DIRS "brain".
# Shell-Startdateien, aus denen `.`/`source`-Ziele geparst und ro gesetzt werden (1d).
STARTUP_PARSE_FILES = (".bashrc", ".profile", ".bash_profile", ".bash_login", ".zshrc")
# `.`/`source <pfad>` (optional mit Backslash `\.`, optional quotiert). Nur Pfad-Extraktion, keine Shell-Auswertung.
_SOURCE_RE = re.compile(r'(?:^|[;&|{}()\s])\\?(?:\.|source)\s+(["\']?)([^"\';&|)\s]+)\1')
# .gitconfig (credential.helper/core.pager/alias koennen Befehle ausfuehren): IMMER read-only.
GITCONFIG_RO = (".gitconfig",)


def _real_existing(paths):
    out = []
    for p in paths:
        rp = os.path.realpath(p)
        if os.path.exists(rp) and not os.path.islink(rp):
            out.append(rp)
    return out


def _is_secret_name(name: str) -> bool:
    low = name.lower()
    _, ext = os.path.splitext(low)
    if name == ".env" or low.endswith(".env") or name.startswith(".env"):
        return True                      # .env, foo.env, .env.bak.x, .env.save
    if "credential" in low:
        return True
    if ext in SECRET_EXT:
        return True
    if low.endswith("-secrets") or low.endswith(".secrets") or low == "secrets":
        return True                      # .kadiv2-secrets, project .secrets
    if name in SECRET_NAMES:
        return True                      # .netrc/.npmrc/.pgpass/.bash_history/...
    if low.startswith("id_") and not low.endswith(".pub"):
        return True                      # id_rsa/id_ed25519/id_ecdsa (private SSH keys)
    return False


@dataclass(frozen=True)
class RunDirs:
    """Ephemere Pfade eines einzelnen Laufs (vom Runner angelegt)."""
    root: str
    workspace: str
    home: str
    codex_home: str
    authdir: str
    tmp: str

    @property
    def auth_copy(self) -> str:
        return os.path.join(self.authdir, "auth.json")

    @property
    def codex_home_auth_link(self) -> str:
        return os.path.join(self.codex_home, "auth.json")


def base_env(dirs: RunDirs, *, home_override: str | None = None) -> dict:
    return {
        "HOME": home_override or dirs.home,
        "PATH": "/usr/bin:/bin",
        "LANG": "C.UTF-8",
        "TMPDIR": dirs.tmp,
        "CODEX_HOME": dirs.codex_home,
        "NO_COLOR": "1",
    }


def _auth_rules(dirs: RunDirs, writable_auth: bool) -> tuple[list, list]:
    """Gibt (file_ro, file_rw) fuer die auth-KOPIE zurueck. authdir hat sonst KEINE Regel
    (kein rw-Vorfahr) -> Kopie ist im Default read-only."""
    if writable_auth:
        return [], [dirs.auth_copy]
    return [dirs.auth_copy], []


def text_profile(dirs: RunDirs, *, codex_bin: str, rlimits: dict,
                 writable_auth: bool = False, tcp_ports=(443,), min_abi: int = 4) -> dict:
    file_ro, file_rw = _auth_rules(dirs, writable_auth)
    codex_bin = os.path.realpath(codex_bin)      # N4: keine Symlinks in der Konfig
    return {
        "min_abi": min_abi,
        "cwd": dirs.workspace,
        "file_x": [codex_bin],
        "ro": list(_real_existing(SYSTEM_RO_DIRS)),
        "file_ro": list(_real_existing(SYSTEM_RO_FILES)) + file_ro,
        "file_rw": _real_existing(("/dev/null",)) + file_rw,
        # codex_home rw (Codex schreibt dort tmp/state); authdir NICHT rw -> auth-Kopie bleibt read-only
        "rw": [dirs.workspace, dirs.home, dirs.codex_home, dirs.tmp],
        "tcp_connect": list(tcp_ports),
        "env": base_env(dirs),
        "rlimits": dict(rlimits),
    }


# --------------------------------------------------------------------------- descent

def _descend(root: str, blocked: frozenset, *, follow_symlinks: bool = False):
    """Liefert (abspath, is_dir) fuer Baeume/Dateien, die ganz freigegeben werden koennen,
    ohne einen blockierten Pfad einzuschliessen. Blockierte Pfade (und ihre Teilbaeume)
    bekommen KEINE Regel. Symlinks werden uebersprungen (N4 / kein Tree-Ausbruch)."""
    root = os.path.abspath(root)
    if root in blocked or not os.path.isdir(root) or os.path.islink(root):
        return
    stack = [root]
    while stack:
        d = stack.pop()
        inside = any(b != d and b.startswith(d + os.sep) for b in blocked)
        if not inside:
            yield (d, True)                 # ganzer Teilbaum sicher
            continue
        try:
            names = sorted(os.listdir(d))
        except OSError:
            continue
        for name in names:
            cp = os.path.join(d, name)
            if cp in blocked:
                continue
            if os.path.islink(cp) and not follow_symlinks:
                continue
            try:
                is_dir = os.path.isdir(cp) and not os.path.islink(cp)
                is_file = os.path.isfile(cp) and not os.path.islink(cp)
            except OSError:
                continue
            if is_dir:
                if any(b == cp or b.startswith(cp + os.sep) for b in blocked):
                    stack.append(cp)        # tiefer absteigen
                else:
                    yield (cp, True)
            elif is_file:
                yield (cp, False)


def scan_home_profile(home_root: str, *, prune_names=PRUNE_DIR_NAMES, extra_prune_paths=(),
                      want_git: bool = True) -> tuple[set, set]:
    """EIN Durchlauf ueber home_root. Liefert (secret_files, git_protect):
      secret_files : Pfade nach Namensmuster verdaechtiger Dateien (NIE Inhalt).
      git_protect  : <repo>/.git/hooks-Verzeichnisse und <repo>/.git/config-Dateien (Startschutz H1).
    node_modules/.git/.cache/.cargo... werden fuer den Secret-Scan ausgelassen (haelt die Allowlist
    klein); .git wird aber VOR dem Prunen fuer git_protect ausgewertet."""
    home_root = os.path.abspath(home_root)
    prune_paths = {os.path.abspath(p) for p in extra_prune_paths}
    secret, git = set(), set()
    for dirpath, dirnames, filenames in os.walk(home_root, topdown=True, followlinks=False):
        if want_git and ".git" in dirnames:
            g = os.path.join(dirpath, ".git")
            hooks, cfg = os.path.join(g, "hooks"), os.path.join(g, "config")
            if os.path.isdir(hooks) and not os.path.islink(hooks):
                git.add(hooks)
            if os.path.isfile(cfg) and not os.path.islink(cfg):
                git.add(cfg)
        dirnames[:] = [d for d in dirnames
                       if d not in prune_names and os.path.join(dirpath, d) not in prune_paths]
        for fn in filenames:
            if _is_secret_name(fn):
                secret.add(os.path.join(dirpath, fn))
    return secret, git


def scan_secret_files(home_root: str, *, prune_names=PRUNE_DIR_NAMES, extra_prune_paths=()) -> set:
    """Rueckwaertskompatible Schnittstelle: nur die Secret-Pfade (siehe scan_home_profile)."""
    return scan_home_profile(home_root, prune_names=prune_names,
                             extra_prune_paths=extra_prune_paths, want_git=False)[0]


def hard_credential_blocklist(home_root: str) -> set:
    """HART gesperrte Credential-Pfade. Fuer Symlinks wird ZUSAETZLICH das realpath-Ziel gesperrt,
    damit ein Symlink (z. B. ~/.ssh -> ~/keystore) nicht am Ziel vorbei Zugriff gewaehrt (Review #5)."""
    home_root = os.path.abspath(home_root)
    out = set()
    for rel in HARD_CREDENTIAL_DIRS + HARD_CREDENTIAL_FILES:
        p = os.path.abspath(os.path.join(home_root, rel))
        out.add(p)
        if os.path.lexists(p):
            out.add(os.path.realpath(p))      # Symlink-Ziel ebenfalls sperren
    return out


def claude_blocklist(home_root: str = "/home/aria") -> set:
    """Pfade, die im tools-Profil WEDER lesbar NOCH ausfuehrbar sein duerfen: der produktive
    claude (ein Start toetet das Telegram-Plugin). Alle Varianten + realpath-Ziele."""
    home_root = os.path.abspath(home_root)
    cand = [
        "/usr/lib/node_modules/@anthropic-ai",            # claude-code-Paket (Binary + JS)
        os.path.join(home_root, ".local/share/claude"),   # versionierte Binaries
        os.path.join(home_root, ".local/state/claude"),
        os.path.join(home_root, ".local/bin/claude"),
        "/usr/bin/claude", "/bin/claude",
    ]
    blocked = set()
    for p in cand:
        if os.path.lexists(p):
            blocked.add(os.path.abspath(p))
            rp = os.path.realpath(p)
            if os.path.lexists(rp):
                blocked.add(rp)
    return blocked


def build_tree(root: str, blocked: frozenset) -> tuple[list, list]:
    """Abstieg ueber root ohne die gesperrten Pfade: (ganz freigebbare Teilbaeume, Einzeldateien).
    Welches Recht die Eintraege bekommen (ro oder rw), entscheidet der Aufrufer."""
    tree_dirs, tree_files = [], []
    for path, is_dir in _descend(root, blocked):
        (tree_dirs if is_dir else tree_files).append(path)
    return tree_dirs, tree_files


def build_home_rw(home_root: str, blocked: frozenset) -> tuple[list, list]:
    """Historischer Name: bis E44 wurde damit das ganze Home rw gewaehrt. Seit Option 2 nutzt
    tools_profile build_tree fuer den LESE-Baum des Homes und je Allowlist-Ordner fuer dessen rw-Baum;
    der Helfer bleibt als reiner Abstieg erhalten (Descent-Test)."""
    return build_tree(home_root, blocked)


def build_exec_rx(exec_roots, blocked: frozenset) -> tuple[list, list]:
    rx_dirs, x_files = [], []
    for root in exec_roots:
        if not os.path.isdir(root):
            continue
        for path, is_dir in _descend(root, blocked):
            (rx_dirs if is_dir else x_files).append(path)
    return rx_dirs, x_files


def _resolve_ro_targets(home_root: str, rels, abses) -> tuple[set, set]:
    """Loest read-only-Ziele auf realpaths auf (N4: keine Symlinks in der Konfig). Liefert
    (ro_dir_targets, ro_file_target_paths) — jeweils existierende, symlink-freie realpaths."""
    ro_dirs, ro_files = set(), set()
    cand = [os.path.join(home_root, r) for r in rels] + list(abses)
    for p in cand:
        if not os.path.lexists(p):
            continue
        rp = os.path.realpath(p)
        if not os.path.exists(rp):
            continue
        (ro_dirs if os.path.isdir(rp) else ro_files).add(rp)
    return ro_dirs, ro_files


def top_level_startup_files(home_root: str, hard_block: frozenset) -> set:
    """Re-Review 2, 1a: ALLE regulaeren Top-Level-Dotfiles (Name beginnt mit '.') plus die
    Anweisungsdateien CLAUDE.md/AGENTS.md als read-only. Ausgenommen: Secrets (bleiben HART gesperrt,
    nicht nur ro -> sonst waeren sie lesbar) und bereits hart gesperrte Pfade (.claude.json/.aria-chat.db).
    Symlinks werden uebersprungen (N4). Nicht existierende Namen (z. B. latentes .bash_aliases) koennen
    gar nicht neu angelegt werden, weil home_root selbst keine rw-/MAKE_REG-Regel erhaelt (siehe tools_profile)."""
    home_root = os.path.abspath(home_root)
    out = set()
    try:
        names = os.listdir(home_root)
    except OSError:
        return out
    for name in names:
        is_dot = name.startswith(".")
        if not is_dot and name not in STARTUP_RO_TOPLEVEL:
            continue
        p = os.path.join(home_root, name)
        if os.path.islink(p) or not os.path.isfile(p):
            continue
        if is_dot and _is_secret_name(name):
            continue                              # Secret -> hart gesperrt, NICHT ro (bliebe sonst lesbar)
        if os.path.abspath(p) in hard_block:
            continue
        out.add(os.path.abspath(p))
    return out


def _resolve_home_token(raw: str, home_root: str) -> str | None:
    """Loest ~/$HOME/absolute/relative Shell-Tokens auf home_root auf. KEINE Shell-Auswertung:
    unaufloesbare Variablen ($NVM_DIR o. ae.) -> None (uebersprungen, robust)."""
    raw = raw.strip().strip('"').strip("'").strip()
    if not raw:
        return None
    if raw == "~" or raw == "$HOME" or raw == "${HOME}":
        return home_root
    for pref in ("~/", "$HOME/", "${HOME}/"):
        if raw.startswith(pref):
            return os.path.join(home_root, raw[len(pref):])
    if raw.startswith("/"):                       # absolut: nur akzeptieren, wenn unter home_root
        ap = os.path.abspath(raw)
        return ap if ap == home_root or ap.startswith(home_root + os.sep) else None
    if "$" in raw or raw.startswith("~"):         # unaufloesbare Variable -> nicht raten
        return None
    return os.path.join(home_root, raw)           # bare relativ: Login-Shell-cwd ist home


def parse_sourced_paths(home_root: str, startup_files=STARTUP_PARSE_FILES) -> set:
    """1d: parst `.`/`source <pfad>` aus den Shell-Startdateien, loest ~/$HOME auf, liefert
    existierende, symlink-freie, absolute Datei-Ziele unter home_root. Einfach und robust, kein eval."""
    home_root = os.path.abspath(home_root)
    out = set()
    for rel in startup_files:
        sf = os.path.join(home_root, rel)
        if os.path.islink(sf) or not os.path.isfile(sf):
            continue
        try:
            with open(sf, "r", encoding="utf-8", errors="replace") as fh:
                text = fh.read()
        except OSError:
            continue
        for line in text.splitlines():
            if line.lstrip().startswith("#"):
                continue
            for m in _SOURCE_RE.finditer(line):
                tgt = _resolve_home_token(m.group(2), home_root)
                if tgt is None or os.path.islink(tgt):
                    continue
                ap = os.path.abspath(tgt)
                if (ap == home_root or ap.startswith(home_root + os.sep)) and os.path.isfile(ap):
                    out.add(ap)
    return out


def read_user_crontab(timeout: float = 5.0) -> tuple[list, str | None]:
    """`crontab -l` des aufrufenden Users AUSSERHALB des Kaefigs. Liefert (zeilen, warnung_oder_None).
    'no crontab for ...' (rc!=0) ist KEINE Warnung (leeres Ergebnis); ein echter Fehler (Timeout/ENOENT)
    ergibt leere Menge + Warnung (Owner-Vorgabe: NICHT fail-closed, aber sichtbar machen)."""
    try:
        p = subprocess.run(["/usr/bin/crontab", "-l"], capture_output=True, timeout=timeout,
                           check=False, env={"PATH": "/usr/bin:/bin"})
    except (OSError, subprocess.TimeoutExpired) as exc:
        return [], f"crontab_read_failed:{type(exc).__name__}"
    if p.returncode != 0:
        return [], None                           # kein Crontab vorhanden -> leer, keine Warnung
    return p.stdout.decode("utf-8", "replace").splitlines(), None


def resolve_cron_targets(home_root: str, cron_targets: list | None) -> tuple[set, set, list]:
    """1e: extrahiert absolute Pfade unter home_root aus den Crontab-Befehlszeilen und klassifiziert sie
    nach Existenz (Verzeichnis -> ro-dir, u. a. `cd <dir>`; Datei -> ro-file). cron_targets injizierbar
    (Tests); None -> echtes `crontab -l`. Liefert (ro_dirs, ro_files, warnings)."""
    home_root = os.path.abspath(home_root)
    warnings: list = []
    if cron_targets is None:
        lines, warn = read_user_crontab()
        if warn:
            warnings.append(warn)
    else:
        lines = list(cron_targets)
    ro_dirs, ro_files = set(), set()
    token_re = re.compile(re.escape(home_root) + r'(?:/[^\s;:"\'`|&><)]*)?')
    for line in lines:
        s = line.strip()
        if not s or s.startswith("#"):
            continue
        for m in token_re.finditer(line):
            raw = m.group(0).rstrip("/")
            if not raw or os.path.islink(raw):
                continue
            ap = os.path.abspath(raw)
            if ap == home_root or not (ap == home_root or ap.startswith(home_root + os.sep)):
                continue                          # home_root selbst nie ro-setzen (sonst Gesamt-Home ro)
            rp = os.path.realpath(ap)
            if not os.path.exists(rp):
                continue
            (ro_dirs if os.path.isdir(rp) else ro_files).add(ap)
    return ro_dirs, ro_files, warnings


def _worktree_gitdir(path: str) -> str | None:
    """Liefert das realpath-gitdir eines Git-Worktrees, dessen `.git` eine DATEI ist ('gitdir: <pfad>'),
    sonst None (kein Worktree, Hauptcheckout mit .git-Verzeichnis, Symlink, oder Fehler)."""
    gf = os.path.join(path, ".git")
    try:
        if os.path.islink(gf) or not os.path.isfile(gf):
            return None
        with open(gf, "r", encoding="utf-8", errors="replace") as fh:
            line = fh.readline().strip()
    except OSError:
        return None
    if line.startswith("gitdir:"):
        return os.path.realpath(line[len("gitdir:"):].strip())
    return None


def _worktrees_container(repo_root: str) -> str | None:
    """<repo_root>/.git/worktrees als realpath - egal ob repo_root der Hauptcheckout (.git-Verzeichnis)
    oder selbst ein Worktree (.git-Datei zeigt auf .../.git/worktrees/<name>) ist."""
    g = os.path.join(repo_root, ".git")
    if os.path.isdir(g) and not os.path.islink(g):
        return os.path.realpath(os.path.join(g, "worktrees"))
    gd = _worktree_gitdir(repo_root)
    if gd:
        parent = os.path.dirname(gd)
        if os.path.basename(parent) == "worktrees":
            return parent
    return None


def sibling_worktrees(home_root: str, repo_root: str | None) -> set:
    """1g (+ Re-Review-2-Erweiterung): Geschwister-Repos direkt unter home_root wie repo_root hart sperren.
    Zwei Erkennungswege:
      (a) echte Git-Worktrees: `.git` ist eine DATEI, die auf <repo_root>/.git/worktrees/ zeigt.
      (b) Namens-Stamm des Hauptrepos: Geschwister ohne .git (z. B. manuelle Kopie aria-v3-shadow-test)
          teilen den Stamm des Hauptrepo-Namens (aria-v3-dev -> Stamm 'aria-v3'); alle home-Kinder
          'aria-v3-*' sind Repo-Kopien/-Zustaende und werden gesperrt. Heuristik bewusst konservativ
          (Stamm >= 3 Zeichen), dokumentiert in docs/SICHERHEIT.md. repo_root selbst ist self_block."""
    home_root = os.path.abspath(home_root)
    if not repo_root:
        return set()
    container = _worktrees_container(repo_root)
    repo_root_abs = os.path.abspath(repo_root)
    stem = None
    if container:
        main_repo = os.path.dirname(os.path.dirname(container))   # <main>/.git/worktrees -> <main>
        base = os.path.basename(main_repo)
        cand = base.rsplit("-", 1)[0] if "-" in base else base
        if len(cand) >= 3:
            stem = cand
    elif len(os.path.basename(repo_root_abs)) >= 3:
        # Kein Git-Checkout (ausgerollte Kopie, z. B. /home/aria/aria-v3): der eigene Verzeichnisname ist der
        # Stamm, sonst fiele die Familien-Sperre still weg (auf main war dieses Layout fail-closed).
        stem = os.path.basename(repo_root_abs)
    out = set()
    try:
        names = os.listdir(home_root)
    except OSError:
        return out
    for name in names:
        d = os.path.join(home_root, name)
        if not os.path.isdir(d) or os.path.islink(d):
            continue
        if os.path.abspath(d) == repo_root_abs:
            continue
        gd = _worktree_gitdir(d)
        # container ist None, wenn repo_root kein Git-Checkout ist (z. B. ausgerollte Kopie ohne .git):
        # dann gibt es keine echten Worktrees dieses Repos (vorher TypeError -> PROFILE_BUILD_FAILED).
        is_worktree = bool(gd) and bool(container) and (gd == container or gd.startswith(container + os.sep))
        is_family = bool(stem) and (name == stem or name.startswith(stem + "-"))
        if is_worktree or is_family:
            out.add(os.path.abspath(d))
    return out


# --------------------------------------------------------------------------- Schreib-Allowlist (E44)

class WriteAllowlistError(ValueError):
    """Unzulaessiger Eintrag in codex.tools_write_allowlist. Immer fail-closed: kein Lauf, keine
    stille Korrektur (ein still wirkungsloser oder zu weiter Eintrag waere schlimmer als ein Fehler)."""


def _inside_or_equal(path: str, root: str) -> bool:
    return path == root or path.startswith(root + os.sep)


def _with_realpaths(paths) -> set:
    out = set()
    for p in paths:
        if not p:
            continue
        ap = os.path.abspath(p)
        out.add(ap)
        if os.path.lexists(ap):
            out.add(os.path.realpath(ap))
    return out


def validate_write_allowlist(entries, *, home_root: str, state_dir: str | None = None,
                             repo_root: str | None = None, runs_root: str | None = None,
                             blocked=(), read_only=()) -> tuple[str, ...]:
    """E44 Option 2: prueft die ausdruecklich freigegebenen Schreib-Ordner des tools-Profils und
    liefert sie (ohne Duplikate, Reihenfolge erhalten) zurueck. Jede Verletzung -> WriteAllowlistError.

    Ein Eintrag muss sein: str, absolut, kanonisch (normpath-gleich: kein '..', '.', '//' oder
    abschliessender '/'), ECHT unterhalb von home_root (nie home_root selbst, nichts ausserhalb, weil
    das tools-Profil ausserhalb des Homes keinen Lesezugriff gewaehrt), ohne versteckte Komponente
    ('.ssh', '.config', '.cache', '.git', '.venv' ... sind Zugangsdaten/Konfiguration/Caches/Hooks,
    keine Projektordner), existierendes Verzeichnis, weder selbst Symlink noch mit Symlink im Pfad
    (realpath-gleich). Er darf weder gleich noch unterhalb eines gesperrten Bereichs liegen: harte
    Credential-Sperren (inkl. Symlink-Ziele), claude, state_dir, laufendes Repo und Geschwister
    (aria-v3-*), die Startschutz-Verzeichnisse (STARTUP_RO_DIRS: scripts, brain, state, ...), sowie die
    beim Profilbau ermittelten `blocked`/`read_only`-Mengen (Secrets, Lauf-Root, Cron-Ziele, Hooks).
    Eine rw-Regel auf einem Unterordner eines gesperrten Bereichs wuerde ihn per Landlock-Union wieder
    oeffnen - darum ist "darunter" verboten; ein Eintrag OBERHALB gesperrter Pfade ist erlaubt (der
    Abstieg in tools_profile schneidet sie heraus). runs_root (codex.workspace_root): weder gleich noch
    darin noch darueber - sonst waeren die Lauf-Verzeichnisse paralleler Laeufe schreibbar."""
    home_root = os.path.abspath(home_root)
    if isinstance(entries, (str, bytes)) or not isinstance(entries, (list, tuple)):
        raise WriteAllowlistError("list of absolute directory paths expected")
    if not entries:
        return ()                                 # Default: kein Dateisystemzugriff beim Laden der Konfig
    protected = (hard_credential_blocklist(home_root) | claude_blocklist(home_root)
                 | _with_realpaths((state_dir, repo_root))
                 | _with_realpaths(os.path.join(home_root, rel) for rel in STARTUP_RO_DIRS)
                 | sibling_worktrees(home_root, repo_root)
                 | {os.path.abspath(p) for p in blocked})
    ro_protected = {os.path.abspath(p) for p in read_only}
    runs = _with_realpaths((runs_root,))
    out: list = []
    for raw in entries:
        if not isinstance(raw, str) or not raw or "\x00" in raw:
            raise WriteAllowlistError("non-empty path strings expected")
        if not os.path.isabs(raw):
            raise WriteAllowlistError(f"{raw}: absolute path expected")
        if os.path.normpath(raw) != raw:
            raise WriteAllowlistError(f"{raw}: canonical path expected (no '..', '.', '//' or trailing '/')")
        if raw == home_root:
            raise WriteAllowlistError(f"{raw}: home_root itself is never writable")
        if not raw.startswith(home_root + os.sep):
            raise WriteAllowlistError(f"{raw}: must lie below home_root {home_root}")
        if any(part.startswith(".") for part in os.path.relpath(raw, home_root).split(os.sep)):
            raise WriteAllowlistError(f"{raw}: hidden path component rejected "
                                      "(credentials/config/caches/.git are no project folders)")
        try:
            st = os.lstat(raw)
        except OSError as exc:
            raise WriteAllowlistError(f"{raw}: does not exist ({type(exc).__name__})") from exc
        if stat.S_ISLNK(st.st_mode):
            raise WriteAllowlistError(f"{raw}: symlink rejected")
        if not stat.S_ISDIR(st.st_mode):
            raise WriteAllowlistError(f"{raw}: directory expected")
        if os.path.realpath(raw) != raw:
            raise WriteAllowlistError(f"{raw}: path contains a symlink")
        for p in sorted(protected):
            if _inside_or_equal(raw, p):
                raise WriteAllowlistError(f"{raw}: inside blocked area {p}")
        for p in sorted(ro_protected):
            if _inside_or_equal(raw, p):
                raise WriteAllowlistError(f"{raw}: inside read-only protected area {p}")
        for p in sorted(runs):
            if _inside_or_equal(raw, p) or _inside_or_equal(p, raw):
                raise WriteAllowlistError(f"{raw}: overlaps the run container {p} (codex.workspace_root)")
        if raw not in out:
            out.append(raw)
    return tuple(out)


def tools_profile(dirs: RunDirs, *, codex_bin: str, rlimits: dict, home_root: str = "/home/aria",
                  writable_auth: bool = False, min_abi: int = 4,
                  secret_files: set | None = None, exec_roots=TOOLS_EXEC_ROOTS,
                  state_dir: str | None = None, repo_root: str | None = None,
                  protect_startup: bool = True, startup_git: set | None = None,
                  cron_targets: list | None = None, write_allowlist=(),
                  runs_root: str | None = None) -> dict:
    """B+-Profil. secret_files/startup_git koennen fuer Tests injiziert werden; sonst wird gescannt.

    state_dir/repo_root: Gateway-Zustand (Budget/Nonces/Schluessel) und das laufende V3-Repo werden
      komplett gesperrt (Selbstmodifikation/Abrechnungs-Umgehung, Review #6). Die ephemeren Lauf-
      Verzeichnisse liegen zwar unter state_dir, werden aber per expliziter rw-Regel wieder gewaehrt.
    protect_startup (Owner TG 12861, Default an): Startdateien/Hooks/Brain read-only (Review H1).
    write_allowlist (E44 Option 2, Default leer): die EINZIGEN Home-Ordner mit Schreibrecht. Das Home
      ist sonst nur lesbar; geschrieben wird nur in den ephemeren Lauf-Verzeichnissen (Workspace,
      Lauf-Home, CODEX_HOME, TMPDIR), /dev/null und - mit writable_auth - der auth-KOPIE. Jeder Eintrag
      wird HIER erneut gegen die beim Profilbau ermittelten Sperren geprueft (validate_write_allowlist,
      Fehler -> WriteAllowlistError); gesperrte/ro-Pfade darunter werden aus seinem rw-Baum geschnitten.
    runs_root: Container der Lauf-Verzeichnisse (codex.workspace_root); kein Allowlist-Eintrag darf ihn
      ueberlappen (vom CagedCodexRunner gesetzt)."""
    home_root = os.path.abspath(home_root)
    codex_bin = os.path.realpath(codex_bin)      # N4: keine Symlinks in der Konfig
    claude = claude_blocklist(home_root)
    hard = hard_credential_blocklist(home_root)

    if secret_files is None or startup_git is None:
        scanned_secret, scanned_git = scan_home_profile(home_root, extra_prune_paths=[dirs.root],
                                                        want_git=protect_startup)
        if secret_files is None:
            secret_files = scanned_secret
        if startup_git is None:
            startup_git = scanned_git if protect_startup else set()

    warnings: list = []
    # Selbstschutz: Gateway-Zustand und das laufende Repo komplett sperren (nur, soweit unter home).
    self_block = set()
    for p in (state_dir, repo_root):
        if p and os.path.abspath(p) != home_root:
            self_block.add(os.path.abspath(p))
            if os.path.lexists(p):
                self_block.add(os.path.realpath(p))
    # Re-Review 2, 1g: Geschwister-Worktrees desselben Repos ebenso hart sperren wie repo_root.
    if protect_startup:
        self_block |= sibling_worktrees(home_root, repo_root)

    # HART gesperrt (keine Regel, auch keine ro): claude, Credentials, Secrets, Selbstschutz, Lauf-Root.
    # VOR den ro-Zielen bestimmt, weil der Top-Level-Dotfile-Sweep Secrets/hart Gesperrte ausnehmen muss.
    hard_block = frozenset(claude | hard | set(secret_files) | self_block | {os.path.abspath(dirs.root)})

    # read-only-Ziele: .gitconfig immer; Startdateien/Hooks/Brain/Dotfiles/gesourcte/cron nur mit protect_startup.
    ro_dir_targets, ro_file_targets = _resolve_ro_targets(home_root, GITCONFIG_RO, ())
    if protect_startup:
        d, f = _resolve_ro_targets(home_root, STARTUP_RO_FILES + STARTUP_RO_DIRS + STARTUP_RO_FILESET
                                   + STARTUP_RO_TOPLEVEL, STARTUP_RO_ABS)
        ro_dir_targets |= d
        ro_file_targets |= f
        # 1a: alle regulaeren Top-Level-Dotfiles + CLAUDE.md/AGENTS.md (Secrets bleiben hart gesperrt).
        ro_file_targets |= top_level_startup_files(home_root, hard_block)
        # 1d: aus den Shell-Startdateien gesourcte Dateien (.cargo/env, .bash_aliases, ...).
        ro_file_targets |= parse_sourced_paths(home_root)
        # 1e: Cron-Ziele des aufrufenden Users (ausserhalb des Kaefigs ermittelt).
        cron_d, cron_f, cron_warn = resolve_cron_targets(home_root, cron_targets)
        ro_dir_targets |= cron_d
        ro_file_targets |= cron_f
        warnings += cron_warn
        # git-Hooks/-config: hooks sind Verzeichnisse, config Dateien.
        for g in startup_git:
            if os.path.islink(g):
                continue
            (ro_dir_targets if os.path.isdir(g) else ro_file_targets).add(os.path.abspath(g))

    # read-only-Ziele zusaetzlich aus den rw-Baeumen der Allowlist schneiden (sonst gaebe ihnen ein
    # rw-Vorfahr Schreibrecht). Eine ro-Datei, die zugleich ein ro-Dir-Nachkomme ist, ist unschaedlich.
    ro_block = ro_dir_targets | ro_file_targets

    # E44 Option 2: das Home ist nur noch LESBAR. Der Lese-Baum spart nur die HART gesperrten Pfade aus
    # (Credentials/Secrets/claude/Selbstschutz/Lauf-Root bleiben ohne jede Regel); ro-Ziele sind lesbar.
    # Vor E44 lieferte derselbe Abstieg (dort ueber hard_block|ro_block) die rw-Regeln des ganzen Homes.
    home_ro_dirs, home_ro_files = build_tree(home_root, hard_block)

    # Schreib-Allowlist: erneut pruefen (Dateisystem kann sich seit dem Laden der Konfig geaendert haben,
    # und erst hier sind Secrets/Cron-Ziele/Hooks/Lauf-Root bekannt), dann je Eintrag absteigen und dabei
    # HART gesperrte UND ro-Ziele aussparen -> z. B. <repo>/.git/hooks bleibt im Allowlist-Repo ro.
    allow = validate_write_allowlist(write_allowlist, home_root=home_root, state_dir=state_dir,
                                     repo_root=repo_root, runs_root=runs_root,
                                     blocked=hard_block, read_only=ro_block)
    rw_dirs, rw_files = [], []
    for entry in allow:
        entry_dirs, entry_files = build_tree(entry, frozenset(hard_block | ro_block))
        rw_dirs += entry_dirs
        rw_files += entry_files

    # read-only-Baeume: pro Ziel absteigen und dabei die HART gesperrten Pfade aussparen
    # (z. B. .config read-only, aber .config/gh bleibt gesperrt; ein Secret in ~/brain bleibt gesperrt).
    ro_dirs, ro_files_from_dirs = [], []
    for root in sorted(ro_dir_targets):
        for path, is_dir in _descend(root, hard_block):
            (ro_dirs if is_dir else ro_files_from_dirs).append(path)
    ro_file_list = sorted(ro_file_targets | set(ro_files_from_dirs))

    # Exec: System + node/bun/nvm ausfuehrbar, aber claude/Credentials/Secrets/Selbstschutz NICHT
    # (Landlock ist Union -> der Exec-Baum darf gesperrte Secrets nicht wieder lesbar machen, Review #2).
    rx_dirs, x_files = build_exec_rx(exec_roots, hard_block)

    file_ro, file_rw = _auth_rules(dirs, writable_auth)
    cfg = {
        "min_abi": min_abi,
        "cwd": dirs.workspace,
        # Lesen: System-/etc + Home-Lese-Baum + (redundant, Verteidigung in der Tiefe) die ro-Ziel-Baeume
        "ro": list(_real_existing(TOOLS_SYSTEM_RO_DIRS)) + sorted(set(home_ro_dirs) | set(ro_dirs)),
        "rx": rx_dirs,
        "file_x": x_files + [codex_bin],
        "no_x": sorted(claude),                        # Defense-in-depth gegen file_x-Treffer
        "file_ro": (list(_real_existing(SYSTEM_RO_FILES)) + file_ro
                    + sorted(set(home_ro_files) | set(ro_file_list))),
        # file_rw: /dev/null, evtl. auth-Kopie, plus Allowlist-Einzeldateien neben gesperrten Geschwistern
        # (Ebenen mit geschuetzten Eintraegen bekommen keine Ordner-Regel: dort nur Bestandsdateien rw)
        "file_rw": _real_existing(("/dev/null",)) + file_rw + sorted(set(rw_files)),
        # Schreiben NUR: die ephemeren Lauf-Baeume (authdir bewusst NICHT rw) + Allowlist-Baeume (E44).
        # Begruendung je Lauf-Pfad: workspace = cwd/-C und last.txt; codex_home = CODEX_HOME (Sessions,
        # Logs, Zustand, tmp; ersetzt ~/.codex); tmp = TMPDIR; home = ephemeres Lauf-Home (im tools-Profil
        # ungenutzt, weil HOME auf home_root zeigt; harmlos, liegt im Lauf-Root und wird mit ihm geloescht).
        "rw": sorted(set(rw_dirs) | {dirs.workspace, dirs.home, dirs.codex_home, dirs.tmp}),
        "env": base_env(dirs, home_override=home_root),
        "rlimits": dict(rlimits),
        # Nicht-fatale Hinweise (z. B. crontab nicht lesbar). iso_exec ignoriert den Schluessel.
        "warnings": warnings,
        # KEIN tcp_connect -> Netz (TCP/UDP) unrestringiert; AF_UNIX bleibt per seccomp gesperrt.
    }
    return cfg


__all__ = [
    "RunDirs", "text_profile", "tools_profile", "base_env",
    "build_tree", "build_home_rw", "build_exec_rx", "scan_secret_files", "scan_home_profile",
    "claude_blocklist", "hard_credential_blocklist",
    "top_level_startup_files", "parse_sourced_paths", "read_user_crontab",
    "resolve_cron_targets", "sibling_worktrees",
    "WriteAllowlistError", "validate_write_allowlist",
    "SYSTEM_RO_DIRS", "SYSTEM_RO_FILES", "TOOLS_EXEC_ROOTS",
]
