"""Gate 0 policy-test pro `.kilo/` konfiguraci (F1).

Cíl: dokázat deterministickým testem, že bezpečnostní hranice deklarovaná
v `.kilo/kilo.jsonc` a `.kilo/agent/*.md` nelze obejít korektní konfigurací —
stejně jako se testuje kód.

Tento soubor je zcela samostatný:
  * importuje POUZE stdlib + `yaml` + `pytest`,
  * NEimportuje nic z `src/cyberbrain26` (pravidlo zadání),
  * čte pouze soubory v `.kilo/`, nic do repozitáře nezapisuje.

Odkazy: D1—D10, ROADMAP_V6.md §14 (invarianty 1, 2, 3, 5, 6, 12),
`.kilo/context/LESSONS_AND_DECISIONS.md` §1 V2 (predikát, který zruší test
tím, že pak vypadá jako běžící).
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest
import yaml

REPO_ROOT = Path(__file__).resolve().parents[1]
KILO_DIR = REPO_ROOT / ".kilo"
GLOBAL_CONFIG = KILO_DIR / "kilo.jsonc"
AGENT_DIR = KILO_DIR / "agent"

VALID_MODES = {"primary", "subagent", "all"}

# Nástroje, které nesmějí být přítomny jen implicitně (D3 / invariant 6).
MUST_BE_EXPLICIT_DENY = ("bash", "external_directory", "agent_manager", "doom_loop")

# Očekávané doplnění v F4 — chybějící `.md` NEJSOU chyba v F1.
F4_EXPECTED_PENDING = {
    "security-auditor",
    "code-reviewer",
    "threat-modeler",
    "deep-research",
    "implementer",
    "tester",
    "documenter",
    "pricing-researcher",
}


# --------------------------------------------------------------------------
# pomocné funkce
# --------------------------------------------------------------------------
def _strip_jsonc_comments(text: str) -> str:
    """Odstraní `//` komentáře z JSONC textu.

    Scanner jde po řádcích a přeskočuje `//` mimo řetězce.

    ZNÁMÁ MEZERA: scanner neumí `//` uvnitř řetězce — pokud by hodnota
    obsahovala URL typu `"https://..."`, pokusil by se to označit za komentář.
    Pro `.kilo/kilo.jsonc` to nevadí (žádná hodnota neobsahuje `//`),
    ale je to známá mezera tohoto přístupu, kterou je nutné mít na paměti,
    pokud by schéma konfigurace rozšířilo o URL.
    """
    out = []
    in_string = False
    escaped = False

    for line in text.splitlines():
        result = []
        i = 0
        while i < len(line):
            ch = line[i]
            if in_string:
                result.append(ch)
                if escaped:
                    escaped = False
                elif ch == "\\":
                    escaped = True
                elif ch == '"':
                    in_string = False
                i += 1
                continue
            if ch == '"':
                in_string = True
                result.append(ch)
                i += 1
                continue
            if ch == "/" and i + 1 < len(line) and line[i + 1] == "/":
                break
            result.append(ch)
            i += 1
        out.append("".join(result))

    return "\n".join(out)


def _load_global(path: Path = GLOBAL_CONFIG) -> dict:
    raw = path.read_text(encoding="utf-8")
    return json.loads(_strip_jsonc_comments(raw))


def _agent_files() -> list:
    if not AGENT_DIR.is_dir():
        return []
    return sorted(p for p in AGENT_DIR.glob("*.md"))


def _load_agent(path: Path) -> dict:
    """Načte YAML frontmatter z `.md` agenta.

    Vrací `{}` pokud frontmatter chybí — volající test to považuje za chybu.
    """
    text = path.read_text(encoding="utf-8")
    if not text.startswith("---"):
        return {}
    parts = text.split("---", 2)
    if len(parts) < 3:
        return {}
    try:
        meta = yaml.safe_load(parts[1])
    except yaml.YAMLError as e:
        raise RuntimeError(
            f"{path.name}: YAML frontmatter se nepodařilo parsovat ({type(e).__name__}). "
            "Nekorektní YAML zničí soubor — viz .kilo/context/LESSONS_AND_DECISIONS.md §1 V1."
        ) from e
    return meta if isinstance(meta, dict) else {}


def _agent_permission(path: Path) -> dict:
    return _load_agent(path).get("permission") or {}


def _global_permission() -> dict:
    return _load_global().get("permission") or {}


def _resolve(tool: str, agent_permission: dict, global_permission: dict):
    """Efektivní hodnota oprávnění nástroje (model Kilo merge).

    Hodnota agenta má přednost; chybí-li klíč v agentovi, zdědí se globální
    hodnota; chybí-li v obou, vrátí se `None` = nic není nastaveno (fail).
    """
    value = agent_permission.get(tool)
    if isinstance(value, dict) and "*" not in value:
        # Neúplný dict bez catch-all nesmí přepsat globální pravidlo
        # (viz `test_bash_dict_form_contains_star_deny`).
        pass
    elif tool in agent_permission:
        return value
    if tool in global_permission:
        return global_permission[tool]
    return None


def _violations(tool: str, agent_perm: dict, global_perm: dict) -> list:
    """Kontrolní logika pro meta-testy (G) — musí být schopna selhat.

    Vrací seznam porušení. Prázdný seznam = pravidlo dodrženo.
    """
    found = []
    resolved = _resolve(tool, agent_perm, global_perm)
    if resolved == "allow":
        found.append(f"{tool}: resolved value is 'allow'")
    if resolved != "deny":
        found.append(f"{tool}: resolved value is {resolved!r}, expected 'deny'")
    if tool not in agent_perm:
        found.append(f"{tool}: not explicitly denied in agent permission")
    return found


def _write_fake_agent(root: Path, name: str, mode: str, permission: dict) -> Path:
    agent_dir = root / "agent"
    agent_dir.mkdir(parents=True, exist_ok=True)
    md = agent_dir / f"{name}.md"
    front = yaml.safe_dump(
        {"description": "FAKE agent pro meta-test", "mode": mode, "permission": permission},
        sort_keys=True,
        allow_unicode=True,
    )
    md.write_text(f"---\n{front}---\n\n# fake\n", encoding="utf-8")
    return md


# --------------------------------------------------------------------------
# pomocné funkce — proti V2 (predikát, který zruší test)
#
# V2 z `.kilo/context/LESSONS_AND_DECISIONS.md` §1: predikát jako `k != "*"`
# nebo iterace nad prázdnou kolekcí zruší tvrzení tak, že test projde a v
# reportu vypadá jako zelený. Níže jsou helpery, které dávají prázdnou
# kolekci do HRUBÉ selhání místo tichého průchodu. Cílem je mít JEDNO místo,
# kde se to rozhoduje — paralelní kopie logiky by se zase rozutekla.
# --------------------------------------------------------------------------
def _require_agent_files() -> list:
    """Seznam `.md` agentů, nebo HRUBÝ FAIL, když je prázdný.

    Nahrazuje přímé `_agent_files()` ve všech smyčkách, u nichž je uvnitř
    `assert`. Smyčka nad prázdnou kolekcí totiž neprovede žádný assert, a
    pytest to v reportu vypíše jako PASSED.
    """
    files = _agent_files()
    assert files, (
        f"{AGENT_DIR} neobsahuje žádný '*.md' — smyčka by neprošla ani jednou "
        "iterací, žádný assert by se nevykonal a test by vypadal jako zelený "
        "(viz .kilo/context/LESSONS_AND_DECISIONS.md §1 V2)")
    return files


def _primary_agent_files() -> list:
    """Agenti s `mode: primary`, odvozeno z adresáře (nikoli hardcoded)."""
    return [p for p in _agent_files() if _load_agent(p).get("mode") == "primary"]


def _task_allow_targets(permission: dict) -> set:
    """Klíče `task` s hodnotou `"allow"` — VČETNĚ wildcardu `"*"`.

    Záměrně NIC nefiltruje, aby kontrola mohla wildcard odhalit. Filtrující
    varianta je `_named_delegation_targets`.
    """
    task = permission.get("task")
    if not isinstance(task, dict):
        return set()
    return {k for k, v in task.items() if v == "allow"}


def _named_delegation_targets(permission: dict, where: str) -> set:
    """Cíle delegace POJMENOVANÉ; wildcard je vyřazen a PRÁZDNÁ MNOŽINA JE FAIL.

    [V2] Wildcard `{"*": "allow"}` není allowlist. Test, který píše
    `k != "*"` a pak iteruje, nevykoná ani jedno tvrzení a projde. Tento
    helper přesouvá ten predikát na jedno místo a vynucuje si NEPRÁZDNÝ
    seznam jmen, aby kontrola měla co ověřovat.
    """
    raw = _task_allow_targets(permission)
    named = raw - {"*"}
    assert named, (
        f"{where}: žádný POJMENOVANÝ cíl delegace (v `task` nalezeno "
        f"{sorted(raw) if raw else 'nic'}). Wildcard '\"*\"' není allowlist — "
        'predikát `k != "*"` zruší každou smyčku nad jmény agentů a test by '
        "prošel bez jediného ověřeného agenta, přestože vypadá jako zelený "
        "(viz .kilo/context/LESSONS_AND_DECISIONS.md §1 V2). Uveď jména agentů "
        "explicitně.")
    return named


# ==========================================================================
# A. Integrita
# ==========================================================================

def test_global_config_parses():
    """`.kilo/kilo.jsonc` existuje a parsuje se jako JSON po odečtení komentářů.

    Odůvodnění: invariant 1 (ROADMAP_V6.md §14.1) — konfigurace, která se
    nedá přečíst, je totéž co chybějící konfigurace, která by se tiše
    nahradila výchozími hodnotami. Parsing je tedy fail-closed kontrola,
    ne kosmetika. Viz `_strip_jsonc_comments` pro známou mezeru (URL ve
    stringu).
    """
    assert GLOBAL_CONFIG.is_file(), f"chybí {GLOBAL_CONFIG}"
    cfg = _load_global()
    assert isinstance(cfg, dict)
    assert "permission" in cfg, "global config musí mít blok permission"


def test_every_agent_has_parsable_yaml_frontmatter():
    """Každý `*.md` v `.kilo/agent/` musí mít parsovatelné YAML frontmatter.

    Odůvodnění: invariant 5 (§14) — žádné bezpečnostní rozhodnutí nesmí být
    pouze v promptu. Frontmatter je strojové načítané rozhodnutí; pokud se
    nedá parsovat, pravidla se tiše neuplatní a agent běží na výchozích
    hodnotách. Chybějící frontmatter = selhání.

    [V1] Nekorektní YAML nesmí shodit celý soubor testů. `yaml.safe_load`
    vyhazuje `yaml.YAMLError` a nepošle ho dál — chyba se převede na
    `pytest.fail` s názvem souboru a pozicí, aby výstup brány ukazoval
    KTERÝ agent je rozbitý, ne jen traceback helperu.
    """
    files = _require_agent_files()
    for path in files:
        text = path.read_text(encoding="utf-8")
        assert text.startswith("---"), f"{path.name}: chybí YAML frontmatter"
        parts = text.split("---", 2)
        assert len(parts) >= 3, f"{path.name}: nedokončené frontmatter"
        try:
            meta = yaml.safe_load(parts[1])
        except yaml.YAMLError as e:
            mark = getattr(e, "problem_mark", None)
            where = f" (řádek {mark.line + 1}, sloupec {mark.column + 1})" if mark else ""
            pytest.fail(
                f"{path.name}: YAML frontmatter se nepodařilo parsovat{where}: {e}. "
                "Nekorektní YAML zničí soubor — viz .kilo/context/LESSONS_AND_DECISIONS.md §1 V1.",
                pytrace=False,
            )
        assert isinstance(meta, dict), f"{path.name}: frontmatter není mapa"


def test_agent_mode_is_valid_enum():
    """`mode` agenta musí být jedna z {primary, subagent, all}.

    Odůvodnění: D6 / invariant 3 (§14.3) — role agenta určuje, kdo smí
    delegovat. Neplatná hodnota by znamenala tichý fallback na výchozí režim,
    což je invariant 1 (žádný tichý přechod).

    Kolekce je tu záměrně přes `_require_agent_files()`: bez ní by prázdný
    `.kilo/agent/` znamenal nula iterací a PASSED (viz §1 V2).
    """
    for path in _require_agent_files():
        meta = _load_agent(path)
        assert "mode" in meta, f"{path.name}: chybí klíč mode"
        assert meta["mode"] in VALID_MODES, "{}: mode={!r} není v {}".format(
            path.name, meta["mode"], sorted(VALID_MODES))


# Parametry pro kontrolu shody `mode` mezi JSON mapou a frontmatterem (.md).
_AGENT_MODE_PARAMS = [pytest.param(p, id=p.stem) for p in _agent_files()]


def test_agent_parametrization_is_not_empty():
    """Parametrické testy MUSÍ mít neprázdnou množinu parametrů — jinak neexistují.

    Odůvodnění: `pytest.mark.parametrize` nad prázdným seznamem NEHLÁSÍ
    chybu. Test se vůbec nespustí a při běžném běhu vypadá jako zelená brána.
    [V2] je přesně tento tvar: brána, která neexistuje, ale vypadá jako
    brána, která prošla.

    Tento test je tedy kontrolou nad KONTROLAMI, ne nad `.kilo/`. Protože
    se parametrizace odvozuje z adresáře, nový agent v `.kilo/agent/*.md`
    do ní vstoupí automaticky — k vyloučení by byl nutný hardcoded seznam,
    a ten je přesně to, co se při přidání agenta tiše rozsype.
    """
    assert _agent_files(), (
        f"{AGENT_DIR} neobsahuje žádný '*.md' — parametrizované testy by měly "
        "nula parametrů, tj. by se nespustily vůbec, nikoli by selhaly "
        "(viz .kilo/context/LESSONS_AND_DECISIONS.md §1 V2)")

    assert _AGENT_MODE_PARAMS, (
        "sada parametrů pro kontrolu shody `mode` je prázdná — "
        "test_agent_mode_declared_in_config_matches_markdown by se nespustil")

    agent_scope_ids = [p.id for p in _CYBERBRAIN26_SCOPE_PARAMS if p.id != "global"]
    assert len(agent_scope_ids) == len(_agent_files()), (
        f"scope parametrů pro cyberbrain26_* pokrývá {len(agent_scope_ids)} "
        f"agentů, ale v {AGENT_DIR} je {len(_agent_files())} — parametrizace "
        "nesleduje adresář, takže nový agent by do brány nevstoupil")

    assert len(set(agent_scope_ids)) == len(agent_scope_ids), (
        f"duplicitní parametry v cyberbrain26_* scope: {sorted(agent_scope_ids)}")


def test_agent_dir_has_no_subdirectories():
    """`.kilo/agent/` musí být PLOCHÝ.

    Odůvodnění: Kilo skenuje `{agent,agents}/**/*.md` REKURZIVNĚ, zatímco
    `_agent_files()` používá `glob("*.md")` NEREKURZIVNĚ. Vložený `.md` do
    podadresáře by se načetl jako ŽIVÝ agent, ale Gate 0 by ho neviděl —
    a neviděný agent se nechodí. To je [V2] v podobě neviditelného agenta:
    nevymizí, jen není pokrytý bránou.

    Dvě řešení: plochá struktura (tento test), nebo rekurze v
    `_agent_files()`. Bez rozhodnutí vlastníka zůstává to první, protože
    `_agent_files()` se používá i v sekcích B a C, kde by rekurze zahrnula
    i zálohy a pracovní artefakty.
    """
    assert AGENT_DIR.is_dir(), f"chybí {AGENT_DIR}"
    subdirs = sorted(p for p in AGENT_DIR.rglob("*") if p.is_dir())
    assert not subdirs, (
        f"{AGENT_DIR} obsahuje podadresáře: "
        f"{[str(p.relative_to(AGENT_DIR)) for p in subdirs]}. Kilo skenuje "
        "'{agent,agents}/**/*.md' rekurzivně, ale '_agent_files()' jen "
        "nerekurzivně — soubor v podadresáři by byl živý agent, kterého Gate 0 "
        "netestuje.")


@pytest.mark.parametrize("agent_path", _AGENT_MODE_PARAMS)
def test_agent_mode_declared_in_config_matches_markdown(agent_path):
    """`mode` agenta musí být explicitně deklarováno V KONFIGURACI a musí
    souhlasit s frontmatterem `.md`.

    Odůvodnění: [OVĚŘENO] Kilo přiřadí markdown frontmatter k agentovi POUZE
    tehdy, když `agent.<jméno>` v JSON mapě ještě neexistuje. Pokud existuje
    (např. v globálním `~/.config/kilo/kilo.jsonc`, kde je `agent.orchestrator`),
    JSON vyhrává a frontmatter se TIŠE zahazuje. Orchestrátor tak resolveoval
    jako `all` místo `primary` — a `all` by při `task: allow` na orchestrátoru
    znamenal více delegátorů, tedy obchod D6 ("Orchestrátor je jediný
    delegátor"). Rozdíl je tichý, tedy invariant 1 (žádný tichý přechod):
    proto se shoda kontroluje deklarativně na obou místech, bez výjimky.
    """
    name = agent_path.stem
    agents_cfg = _load_global().get("agent")
    assert isinstance(agents_cfg, dict), (
        f"{GLOBAL_CONFIG.name}: 'agent' musí být mapa, dostáno {type(agents_cfg).__name__}")
    assert name in agents_cfg, (
        f"agent '{name}' chybí v mapě 'agent' v {GLOBAL_CONFIG.name} — mode se pak načítá jinde "
        "(globální config) a frontmatter se tiše zahazuje")

    entry = agents_cfg[name]
    assert isinstance(entry, dict) and "mode" in entry, (
        f"agent '{name}': v {GLOBAL_CONFIG.name} chybí explicitní 'mode' — bez něj se mode řídí "
        "jinou konfigurací")

    config_mode = entry["mode"]
    md_mode = _load_agent(agent_path).get("mode")
    assert config_mode == md_mode, (
        f"agent '{name}': nesoulad mode — {GLOBAL_CONFIG.name}={config_mode!r}, frontmatter v {agent_path.name}.md mode={md_mode!r}; "
        "vyhrává JSON, frontmatter se tiše zahazuje")
    assert config_mode in VALID_MODES, f"{name}: config mode={config_mode!r} není v {sorted(VALID_MODES)}"


def test_global_config_declares_all_agents():
    """Mapa `agent` v `.kilo/kilo.jsonc` a soubory `.kilo/agent/*.md` se musí
    shodovat V OBA SMĚRECH.

    Odůvodnění: invariant 1 (žádný tichý přechod). V jednom směru chybí
    deklarace = `mode` agenta se načítá z jiného zdroje a frontmatter se
    zahazuje (viz `test_agent_mode_declared_in_config_matches_markdown`);
    ve druhém směru zápis bez `.md` je mrtvá konfigurace — deklaruje agenta,
    kterého v repu není, takže pravidlo vypadá zajištěné, ale není použitelné.
    Zde není výjimka typu „chybí do F4" (viz `F4_EXPECTED_PENDING`): mrtvý
    zápis je stejně nebezpečný jako chybějící, proto je tvrdý assert.
    """
    agents_cfg = _load_global().get("agent")
    assert isinstance(agents_cfg, dict), (
        f"v {GLOBAL_CONFIG.name} chybí mapa 'agent' s deklarovanými agenty")

    md_names = {p.stem for p in _agent_files()}
    assert md_names, f"žádný agent v {AGENT_DIR}"

    cfg_names = set(agents_cfg)
    missing_in_config = sorted(md_names - cfg_names)
    assert not missing_in_config, (
        f"agenti s {AGENT_DIR.name} ale bez zápisu v mapě 'agent' v {GLOBAL_CONFIG.name}: {missing_in_config}")

    dead_entries = sorted(cfg_names - md_names)
    assert not dead_entries, (
        f"mapa 'agent' v {GLOBAL_CONFIG.name} deklaruje agenty bez {AGENT_DIR.name} souboru (mrtvá "
        f"konfigurace): {dead_entries}")


def test_agent_permission_is_object_not_scalar():
    """`permission` agenta musí být objekt, ne skalární hodnota.

    Odůvodnění: skalární `permission` by znamenalo, že deklarativní pravidla
    agenta nejsou aplikovatelná — veškerá granularita (bash deny, task
    allowlist) by zmizela a zbylo by jedno slovo. To je fail-open.

    Kolekce přes `_require_agent_files()` — prázdný adresář = FAIL, ne PASS.
    """
    for path in _require_agent_files():
        perm = _load_agent(path).get("permission")
        assert perm is not None, f"{path.name}: chybí permission"
        assert isinstance(perm, dict), f"{path.name}: permission musí být dict, dostáno {type(perm).__name__}"


# ==========================================================================
# B. D3 — nikdo nemá bash (invariant 6)
# ==========================================================================

def test_global_bash_denied():
    """Globálně `permission.bash == "deny"`.

    Odůvodnění: D3 a invariant 6 (§14.6) — `bash: deny` je vynuceno dvěma
    nezávislými mechanismy (globální konfigurace + frontmatter agenta).
    Vynechání globálního pravidla by nebylo „výchozí ask", ale chybějící
    pojistka.
    """
    assert _global_permission().get("bash") == "deny"


@pytest.mark.parametrize("tool", MUST_BE_EXPLICIT_DENY)
def test_every_agent_bash_explicitly_denied(tool):
    """Každý agent musí `tool` explicitně uvádět jako `"deny"`.

    Odůvodnění: D3 / invariant 6. Chybějící klíč = selhání, protože tichá
    dědičnost je přesně to, co tato kontrola chytá. Dva nezávislé zápisy
    (    `kilo.jsonc` + frontmatter) jsou záměrem: kdyby jeden chyběl, druhý
    drží hranici, a tato kontrola to odhalí dřív než člověk.

    Kolekce přes `_require_agent_files()`: prázdný adresář by znamenal nula
    iterací a PASSED bez jediného ověřeného agenta (viz §1 V2).
    """
    for path in _require_agent_files():
        perm = _agent_permission(path)
        if (path.stem, tool) in PRIMARY_TOOL_ALLOW_EXCEPTIONS:
            continue  # D14: orchestrator má bash: allow a agent_manager: allow
        assert tool in perm, f"{path.name}: '{tool}' není explicitně uveden — tichá dědičnost"
        assert perm[tool] == "deny", f"{path.name}: {tool}={perm[tool]!r}, očekáváno 'deny'"


def test_bash_dict_form_contains_star_deny():
    """Pokud je `bash` hodnotou dict místo stringu, musí obsahovat `"*": "deny"`.

    Odůvodnění: pravidla se vyhodnocují v konfiguračním pořadí a vyhrává
    POSLEDNÍ SHODA (komentář v `kilo.jsonc`, ROADMAP_V6.md §14.1).
    `bash: {foo: allow}` bez `"*"` by propustilo cokoliv jiného — globální
    deny by byl přepsán.

    Kolekce přes `_require_agent_files()` — bez ní by kontrola běžela jen nad
    globálním scope a přehlédla by formu dict u libovolného agenta.
    """
    files = _require_agent_files()
    scopes = [("global", _global_permission())]
    scopes += [(p.name, _agent_permission(p)) for p in files]
    for where, perm in scopes:
        value = perm.get("bash")
        if isinstance(value, dict):
            assert value.get("*") == "deny", (
                f"{where}: bash dict musí mít '*': 'deny', dostáno {value!r}")


def test_bash_dict_grant_is_not_usable_so_it_must_not_claim_an_exception():
    """Agent, který tvrdí `bash` výjimku, musí mít `bash` jako SKALÁRNÍ `allow`.

    Proč je tenhle test:

    `test_bash_dict_form_contains_star_deny` nad dictem *prohází*, takže korektně
    zapsaný nefunkční grant vypadá jako splněná pojistka. Když je takový agent
    zároveň v `PRIMARY_TOOL_ALLOW_EXCEPTIONS`, testy zaznamenají výjimku, která
    v runtime nemusí existovat — falsný doklad o bezpečnostním stavu, přesně to,
    co tento soubor má chránit (viz `test_detector_rejects_bash_allow`).

    **OPRAVA TVRZENÍ 2026-10-05.** Tento test původně tvrdil „dict nedává nástroj
    (změřeno: `EXIT=126`)". To **nebylo prokázáno**. Dřívější závěr vznikl z jediného
    měření a později vyšel najevo jako chybný:

    | kdy | agent | `bash` | výsledek |
    |---|---|---|---|
    | dřív v téže session | `security-auditor` | `allow` (skalár) | `EXIT=0`, `PROBE_OK` |
    | později v téže session | `security-auditor` | `allow` (skalár) | `EXIT=127`, nástroj nedostupný |

    Stejný agent, stejný grant, stejný příkaz, jiný výsledek. **Dostupnost `bash`
    v této session je nedeterministická**, takže nelze z jediného probe dedukovat,
    že dict tvar nefunguje a skalár funguje.

    Tento test proto **nesoudí o dict tvaru vůbec**. Vyžaduje jedině to, co je
    bezpečné v obou směrech deklarativně: kdo tvrdí výjimku pro `bash`, ať ji má
    zapsanou jako skalární `allow`, protože to je jediná forma, kterou tento soubor
    umí přesně ověřit. Chybějící `PRIMARY_TOOL_ALLOW_EXCEPTIONS` zůstává BLOCKER
    nezávisle na tom, jestli dict funguje.
    """
    for path in _require_agent_files():
        perm = _agent_permission(path)
        if (path.stem, "bash") not in PRIMARY_TOOL_ALLOW_EXCEPTIONS:
            continue
        assert not isinstance(perm.get("bash"), dict), (
            f"{path.name}: je v PRIMARY_TOOL_ALLOW_EXCEPTIONS pro 'bash', ale 'bash' "
            f"je dict {perm['bash']!r}. Výjimka musí být zapsána jako skalární "
            "'allow' — jinak test nemůže ověřit, co výjimka ve skutečnosti dělá, "
            "a záznam se stane falsným dokladem."
        )


def test_agents_claiming_bash_are_exactly_the_measured_ones():
    """Kdo má `bash: allow` — seznam nesmí růst tiše.

    Odůvodnění: každý agent s během na hostovi je samostatné bezpečnostní rozhodnutí
    a musí být evidovaný výjimkou, ne tichou vlastností souboru. Tento test
    neomezuje koho přidat — to je rozhodnutí vlastníka — ale zabraňuje tomu, aby
    přídavný `bash: allow` prošel bez zápisu do `PRIMARY_TOOL_ALLOW_EXCEPTIONS`.
    """
    allowed = {
        path.stem
        for path in _require_agent_files()
        if _agent_permission(path).get("bash") == "allow"
    }
    undeclared = sorted(allowed - {a for a, t in PRIMARY_TOOL_ALLOW_EXCEPTIONS if t == "bash"})
    assert not undeclared, (
        f"agenti s bash: allow, kteří nejsou v PRIMARY_TOOL_ALLOW_EXCEPTIONS: "
        f"{undeclared} — běh na hostovi musí být evidovaná výjimka, ne tichá vlastnost")


# ==========================================================================
# C. Subset — agent nesmí být méně přísný než globál
# ==========================================================================

def test_agent_permission_is_subset_of_global():
    """Agent nesmí být méně přísný než globál: kde globál říká `deny`,
    agent nesmí říct `allow`. Přísnější (`ask`/`deny`) je v pořádku, opačně ne.

    Odůvodnění: ROADMAP_V6.md §14 — bezpečnostní pořadí
    identita → politika → oprávnění. Globální `deny` je podlaha; agent
    frontmatter je deklarativní vrstva nad ní a nesmí ji podkopat.

    DVA předpoklady, oba nyní tvrdé asserty, protože bez nich je smyčka
    uvnitř no-op (viz §1 V2): (a) globální permission musí obsahovat alespoň
    jedno `deny`, jinak se vnitřní smyčka neprovede; (b) kolekce agentů
    nesmí být prázdná.
    """
    glob = _global_permission()
    glob_denies = {tool for tool, value in glob.items() if value == "deny"}
    assert glob_denies, (
        f"{GLOBAL_CONFIG.name}: globální `permission` nemá žádný klíč s hodnotou "
        "'deny' — podlaha, kterou by tato kontrola podkopávala, neexistuje, a "
        "smyčka uvnitř by neprošla ani jednou iterací")

    for path in _require_agent_files():
        perm = _agent_permission(path)
        for tool, gvalue in glob.items():
            if gvalue != "deny":
                continue
            if (path.stem, tool) in PRIMARY_TOOL_ALLOW_EXCEPTIONS:
                continue  # D14: orchestrator má bash: allow a agent_manager: allow
            assert tool not in perm or perm[tool] != "allow", (
                f"{path.name}: {tool}='allow' je méně přísné než globální 'deny'")
            if isinstance(perm.get(tool), dict):
                assert perm[tool].get("*") != "allow", (
                    f"{path.name}: {tool} dict '*'='allow' obchází globální deny")


def test_task_always_uses_allowlist_form():
    """`task` musí být dict s `"*": "deny"` — globálně i v každém agentu
    kromě Orchestrátora (D14).

    Tento test ověřuje jen FORMU (dict s catch-all), ne DÉLKU allowlistu:
    počet agentů, kteří smí delegovat, se řídí jinde
    (`test_only_orchestrator_may_delegate`).

    ZMĚNA oproti původnímu znění: D14 už wildcard `{"*": "allow"}` u
    Orchestrátora nepřipouští. Wildcard byl sice schválený, ale podle pravidla
    „poslední shoda vyhrává" znamenal SKUTEČNÉ právo delegovat na kohokoli a
    zároveň zrušil každou kontrolu, která filtruje `k != "*"` — viz §1 V2 a
    `test_orchestrator_task_allowlist_is_explicit_not_wildcard`.

    Odůvodnění: D6 / invariant 3 (§14.3) — jeden orchestration path.
    Skalární `task` nebo dict bez `"*"` by propustil delegaci na cokoliv,
    tedy paralelní/nezamýšlenou orchestration cestu. Allowlist bez catch-all
    deny není allowlist.
    """
    glob_task = _global_permission().get("task")
    assert isinstance(glob_task, dict), f"global task={glob_task!r} není dict"
    assert glob_task.get("*") == "deny", f"global task chybí '*': 'deny' ({glob_task!r})"

    for path in _require_agent_files():
        task = _agent_permission(path).get("task")
        assert isinstance(task, dict), f"{path.name}: task={task!r} není dict"
        if (path.stem, "task") in PRIMARY_TOOL_ALLOW_EXCEPTIONS:
            # D14: orchestrator je jediný delegátor. Výjimka z requirements na
            # `"*": "deny"` zůstává, protože explicitní allowlist catch-all
            # neobsahuje a musel by být zapsán jinak. Formu a NEPRÁZDNOST
            # orchestrátorova allowlistu kryje
            # `test_orchestrator_task_allowlist_is_explicit_not_wildcard`.
            continue
        assert task.get("*") == "deny", (
            f"{path.name}: task chybí '*': 'deny' ({task!r}) — propustilo by delegaci")


def test_orchestrator_task_allowlist_is_explicit_not_wildcard():
    """Orchestrátor musí mít `task` jako explicitní allowlist, ne wildcard.

    [V2] D14 už wildcard `{"*": "allow"}` nepřipouští. Tento test je
    nezávislý na `test_task_always_uses_allowlist_form` (ten orchestrátora
    přeskočí) a na `test_only_orchestrator_may_delegate` (ten kontroluje
    jen jména, ne formu). Ověřuje, že orchestrátorův `task` je dict s
    konkrétními jmény a žádným `"*"`.

    Odůvodnění: wildcard by znamenal, že orchestrátor může delegovat na
    kohokoli — včetně agentů, kteří neexistují nebo nejsou v `AGENT_DELEGATION_ALLOWLIST`.
    Zároveň by zrušil `test_only_orchestrator_may_delegate`, protože ten
    filtruje `k != "*"` a iteruje jen nad jmény. [V2] — predikát, který
    zruší vlastní test.
    """
    orchestrator_path = AGENT_DIR / "orchestrator.md"
    assert orchestrator_path.is_file(), "chybí orchestrator.md"

    task = _agent_permission(orchestrator_path).get("task")
    assert isinstance(task, dict), f"orchestrator: task={task!r} není dict"

    # Žádný wildcard
    assert "*" not in task, (
        "orchestrator: `task` obsahuje wildcard '*' — to není allowlist, "
        "je to delegace na kohokoli. D14 vyžaduje explicitní jména agentů."
    )

    # Allowlist není prázdný (D14)
    allows = {k for k, v in task.items() if v == "allow"}
    assert allows, (
        "orchestrator: `task` je prázdný — D14 říká, že orchestrátor musí "
        "mít alespoň jedno task: allow"
    )

    # Každý cíl je jméno agentu (nebo očekávaný do F4)
    for name in sorted(allows):
        assert isinstance(name, str) and name, f"orchestrator: neplatný klíč v task: {name!r}"


# Agenti, jejichž deklarovaná role je čisté neexekuční. Podle ROADMAP_V6 §9.1
# PromptMaster "nesmí volat žádný cyberbrain26_* exekuční nástroj" a D6 mu zakazuje
# delegovat. U těchto agentů je `deny` PREFEROVANÉ: `ask` by umožnilo uživateli
# schválit exekuci, čímź by se D6 obešlo. Vynecháním by se hranice oslabila.
NON_EXECUTING_AGENTS = frozenset({
    "code-reviewer",
    "deep-research",
    "documenter",
    "gate3-auditor",
    "implementer",
    "overseer",
    "pricing-researcher",
    "promptmaster",
    "scout",
    "security-auditor",
    "threat-modeler",
})

# Agenti na exekuční cestě, kteří `cyberbrain26_*` potřebují volat. Podle §9.2
# Orchestrátor "řídí security plane". `ask` je zde správné: Kilo promptuje,
# autorizaci provádí Python v AuthorizedExecutionService.
EXECUTION_PATH_AGENTS = frozenset({
    "orchestrator",
    "tester",
    "verifier",
})

# Oba seznamy jsou ODVOZENÉ ze struktury `.kilo/agent/`, ne z pevného seznamu
# jmen — přidání `.md` agenta do něj automaticky vstoupí do
# `test_every_agent_is_classified_for_cyberbrain26` a do parametrických testů.
# Hardcoded seznam jmen je přesně to, co se při přidání agenta tiše rozsype.
_CYBERBRAIN26_SCOPE_PARAMS = (
    [pytest.param(None, id="global")] +
    [pytest.param(p, id=p.stem) for p in _agent_files()]
)


@pytest.mark.parametrize("agent_path", _CYBERBRAIN26_SCOPE_PARAMS)
def test_cyberbrain26_never_allow(agent_path):
    """`cyberbrain26_*` NESMí bít `"allow"` v žádném scope — globálně ani u
    žádného agenta.

    Odúvodnění: invariant 5 (ROADMAP_V6.md §14.5) a D3. `allow` by obešlo
    lidské schválení — nástroj by se dostal k `AuthorizedExecutionService`
    bez člověka v řetězcci `identity → policy → approval → sandbox`.
    Bezpečnostní rozhodnutí nesmí být vydáno konfigurací ani promptem.

    Toto je JEDINÝ globální invariant pro `cyberbrain26_*`. Pravidlo `deny` vs.
    `ask` je rolově závislé a kontroluje ho `test_cyberbrain26_role_appropriate`.
    """
    where = "global" if agent_path is None else agent_path.name
    perm = _global_permission() if agent_path is None else _agent_permission(agent_path)
    value = perm.get("cyberbrain26_*")
    assert value != "allow", (
        f"{where}: cyberbrain26_*='allow' obejde lidské schválení "
        "(invariant 5, §14.5, D3)")


def test_cyberbrain26_role_appropriate(capsys):
    """Hodnota `cyberbrain26_*` musí odpovídat deklarované roli agenta.

    Pravidla:
      * globál v `.kilo/kilo.jsonc` = `"ask"`,
      * `NON_EXECUTING_AGENTS` = `"deny"` NEBO `"ask"`, nikdy `"allow"`.
        Pokud má `"ask"`, vypíše se to jako VAROVÁNÍ (ne selhání) — `ask`
        není chyba, jen slabší varianta.
      * `EXECUTION_PATH_AGENTS` = `"ask"`. `deny` by tu byl skutečná chyba,
        protože tito agenti `cyberbrain26_*` potřebují volat — `deny` by
        udělalo řetězec nedosažitelným.
      * jakákoli jiná hodnota (např. `allow`) je chyba,
      * agent mimo obě množiny se VYPÍŠE jako `unclassified_agent` a test
        NESELEHÁ — v F4 přibydou exekuční subagenti. Test nesmí cpát
        dopředu budoucí konfiguraci.

    TENTO TEST JE ZÁMĚRNĚ NEFAILUJÍCÍ, proto jako doplněk existuje tvrdý
    `test_every_agent_is_classified_for_cyberbrain26`. Záměr: role-aware
    kontrola hodnot zůstává měkká (aby nezastavila postup projektu), ale
    ÚPLNOST registrace rolí je tvrdá. Do té doby je `unclassified_agent`
    jen text v logu, ne porušení.
    """
    glob_value = _global_permission().get("cyberbrain26_*")
    assert glob_value == "ask", (
        f"global cyberbrain26_*={glob_value!r}, očekáváno 'ask'")

    warnings = []
    unclassified = []

    for path in _require_agent_files():
        name = path.stem
        value = _agent_permission(path).get("cyberbrain26_*")

        if name in NON_EXECUTING_AGENTS:
            assert value in ("deny", "ask"), (
                f"{name}: cyberbrain26_*={value!r} — očekáváno 'deny' nebo 'ask', "
                "'allow' je nepřípustné")
            if value == "ask":
                warnings.append(
                    f"{name}: cyberbrain26_*='ask' — přípustné, ale pro neexekuční "
                    "agenta je 'deny' silnější hranice (D6 by šlo "
                    "schválením obejít)")
        elif name in EXECUTION_PATH_AGENTS:
            assert value == "ask", (
                f"{name}: cyberbrain26_*={value!r} — agent je na exekuční cestě a "
                "cyberbrain26_* POTŘEBUJE volat; 'deny' by řetězec udělal "
                "nedosažitelným")
        else:
            unclassified.append(f"{name} (cyberbrain26_*={value!r})")
            continue

        assert value != "allow", (
            f"{name}: cyberbrain26_*='allow' obejde lidské schválení "
            "(invariant 5, §14.5, D3)")

    with capsys.disabled():
        print("\n[Gate 0/F1] cyberbrain26_* role-aware kontrola:")
        print(f"  global = {glob_value!r} (očekáváno 'ask')")
        for name in sorted(NON_EXECUTING_AGENTS | EXECUTION_PATH_AGENTS):
            agent_md = AGENT_DIR / f"{name}.md"
            shown = (_agent_permission(agent_md).get("cyberbrain26_*")
                     if agent_md.is_file() else None)
            kind = ("non-executing" if name in NON_EXECUTING_AGENTS
                    else "execution-path")
            print(f"  {kind}: {name} -> {shown!r}")
        for w in warnings:
            print(f"  VAROVÁNÍ: {w}")
        for u in unclassified:
            print(f"  unclassified_agent: {u} — není v NON_EXECUTING_AGENTS ani "
                  "EXECUTION_PATH_AGENTS, doplň při F4")
        if not warnings and not unclassified:
            print("  (bez varování, bez nezařazených agentů)")


def test_every_agent_is_classified_for_cyberbrain26():
    """KAŽDÝ agent musí mít deklarovanou roli pro `cyberbrain26_*`.

    Odůvodnění: invariant 5 (§14.5) — bezpečnostní rozhodnutí nesmí být
    vydáno konfigurací, ale musí být VYNUCENO kódem. K `cyberbrain26_*` se
    rozhoduje Kilo podle `permission`, a rozdíl `deny` vs. `ask` je rolově
    závislý: bez deklarované role není čím zdůvodnit, proč je tenhle agent
    na `ask` a tenhle na `deny`.

    [V2] v podobě tichého průchodu: `test_cyberbrain26_role_appropriate` vypíše
    nezařazeného agenta jako `unclassified_agent` a NESELEHÁ. Osm z jedenácti
    agentů tak bylo „neklasifikovaných" a brána přitom hlásila PASSED —
    v reportu to vypadá jako otázka k doplnění, ve skutečnosti to je
    neprokázaná hodnota `ask` u agenta, jehož role nikdo neurčil.

    Tento test je TVRDÚ: doplnit roli, nebo přepsat allowlist tak, aby byl
    každý agent pokrytý. `tester`, `implementer` aj ostatní čtenářští
    subagenti jsou `NON_EXECUTING_AGENTS`, pokud nepotřebují volat
    `cyberbrain26_*`; `deny` je pro ně správná volba.
    """
    files = _require_agent_files()
    classified = NON_EXECUTING_AGENTS | EXECUTION_PATH_AGENTS
    assert classified, (
        "NON_EXECUTING_AGENTS i EXECUTION_PATH_AGENTS jsou prázdné — kontrola "
        "úplnosti registrace by neověřovala nic")
    assert not (NON_EXECUTING_AGENTS & EXECUTION_PATH_AGENTS), (
        "agent je současně v NON_EXECUTING_AGENTS i EXECUTION_PATH_AGENTS: "
        f"{sorted(NON_EXECUTING_AGENTS & EXECUTION_PATH_AGENTS)} — role musí být "
        "jednoznačná, jinak se pravidlo určuje dvěma způsoby")

    unclassified = []
    for path in files:
        name = path.stem
        if name in classified:
            continue
        unclassified.append(
            f"{name} (cyberbrain26_*={_agent_permission(path).get('cyberbrain26_*')!r})")

    assert not unclassified, (
        "agenti bez deklarované role pro `cyberbrain26_*`: "
        + ", ".join(sorted(unclassified))
        + ". Zařaď každého do NON_EXECUTING_AGENTS (čtenářský/nekontrolující "
        "agent, správná hodnota je 'deny') nebo do EXECUTION_PATH_AGENTS "
        "(agent na security plane, musí mít 'ask'). Dokud jsou nezařazení, "
        "`test_cyberbrain26_role_appropriate` je pro ně jen nefailující "
        "informační výpis a hodnota 'ask' není ničím podložená.")


# Výjimky: (agent, tool) dvojice, kde smí být "allow" místo globálního "deny".
#
# D14 — výslovně schválené rozhodnutí, pouze pro jediného delegátora:
#   ("orchestrator", "bash"), ("orchestrator", "agent_manager"),
#   ("orchestrator", "edit"), ("orchestrator", "task")
#
# Schváleno vlastníkem 2026-10-02 navíc:
#   - tester: spouští Gate 1/2 (pytest, mypy, semgrep, hash audit chainu).
#     Bez bash by deterministické brány nebylo čím spustit.
#
# `implementer` ZDE NENÍ a nesmí být: schválený model je oddělený autor a
# kontrolor. Implementer mění kód, ale NEMÁ `bash` — testy za něj spouští
# `tester`, kterého si implementer vyžádá přes `task` (`implementer.md:8`
# `{"*": "deny", "tester": "allow"}`). Autor nepodepisuje vlastní změnu.
# Pokud by tu bylo `("implementer", "bash")`, test by přeskočil kontrolu, která
# právě hlídá, že implementer bash nemá — a výjimka by kryla opak záměru.
#
# Každá položka musí být dvojice (jméno, nástroj), nikoli jen jméno: jméno
# bez nástroje by propustilo i nástroje, které výjimka neřeší.
# Invariant: výjimka je navázaná na funkci, ne na `mode`.
PRIMARY_TOOL_ALLOW_EXCEPTIONS = frozenset({
    ("orchestrator", "bash"),
    ("orchestrator", "agent_manager"),
    ("orchestrator", "edit"),
    ("orchestrator", "task"),
    ("tester", "bash"),
    ("security-auditor", "bash"),
    ("gate3-auditor", "bash"),
    # Owner decision 2026-10-05: verifier gets bash so Gate 3 can run the
    # tests it assesses. `edit` stays `deny`, which is what keeps it from
    # "fixing" a test to make it pass.
    ("verifier", "bash"),
    # Owner decision 2026-11-03: deep-research gets bash for read-only
    # `curl` URL verification (no write, no execute). `edit` stays `deny`.
    ("deep-research", "bash"),
})


def test_primary_agents_cannot_edit():
    """Agent s `mode: primary` musí mít `edit: "deny"`, s výjimkou
    `PRIMARY_TOOL_ALLOW_EXCEPTIONS` (D14 — Orchestrátor má `edit: allow`).

    Odůvodnění: D6 / invariant 3 — primární agenti jsou vstupní brána
    (`uživatel → PromptMaster → Orchestrátor`), ne autor změn. Kdyby
    primární agent editoval soubory, změna by se děla mimo bezpečnostní
    řetězec (`AuthorizedExecutionService`), tedy mimo Gate 1/2. Výjimka
    pro Orchestrátora je výslovně schválená rozhodnutím D14 a NEZnamená
    obecné povolení k editaci.
    """
    for path in _agent_files():
        meta = _load_agent(path)
        if meta.get("mode") != "primary":
            continue
        if (path.stem, "edit") in PRIMARY_TOOL_ALLOW_EXCEPTIONS:
            continue
        perm = _agent_permission(path)
        assert "edit" in perm, (
            f"{path.name}: primary agent (mimo výjimku D14) musí mít v `permission` "
            "explicitně nastavený `edit: 'deny'` — nikoli děděný z globálu "
            "(fail-closed proti tiché dědičnosti)")
        assert perm["edit"] == "deny", (
            f"{path.name}: primary agent musí mít edit: 'deny'")


# ==========================================================================
# D. D6 / invariant 3 a 12 — jeden orchestration path
# ==========================================================================

def test_subagent_depth_is_one():
    """`subagent_depth == 3`.

    Odůvodnění: invariant 12 (§14.12) — tvrdý invariant hloubky 3; orchestrátor
    deleguje do tří vrstev subagentů (vrstva 1 → vrstva 2 → vrstva 3).
    Výchozí hodnota Kilo je 1, ale byla změněna na 3 výslovným rozhodnutím
    vlastníka (2026-11-03), protože konfigurace je předmět auditu (ROADMAP_V6.md §14.1).
    """
    assert _load_global().get("subagent_depth") == 3


def test_default_agent_is_a_declared_primary_agent():
    """`default_agent` musí být agentem deklarovaným v mapě `agent` v
    `.kilo/kilo.jsonc` s `mode: primary` (ne `all`).

    Odůvodnění: D11 (vstupní bod je PromptMaster, nikoli Orchestrátor) a
    invariant 3 (§14.3) — jeden primary agent, jediný delegátor. Test
    neurčuje konkrétní jméno (D11 ho změnil), proto ověřuje invariant:
    defaultní agent je primární. `mode: all` by při `task: allow` otevřilo
    delegaci i jiným agentům a obešlo D6.
    """
    cfg = _load_global()
    default_agent = cfg.get("default_agent")
    assert default_agent, (
        f"{GLOBAL_CONFIG.name}: chybí 'default_agent'")

    agents_cfg = cfg.get("agent")
    assert isinstance(agents_cfg, dict), (
        f"{GLOBAL_CONFIG.name}: 'agent' musí být mapa, dostáno {type(agents_cfg).__name__}")
    assert default_agent in agents_cfg, (
        f"default_agent '{default_agent}' chybí v mapě 'agent' v {GLOBAL_CONFIG.name} — efektivní mode se "
        "pak řídí jinak a může skýtat tichý přechod")

    default_entry = agents_cfg[default_agent]
    assert isinstance(default_entry, dict) and "mode" in default_entry, (
        f"default_agent '{default_agent}': v {GLOBAL_CONFIG.name} chybí explicitní 'mode' — efektivní mode "
        "se pak řídí jinou konfigurací")
    effective_mode = default_entry["mode"]
    assert effective_mode == "primary", (
        f"default_agent '{default_agent}': efektivní mode={effective_mode!r} (z {GLOBAL_CONFIG.name}), očekáváno 'primary' "
        "— 'all' by otevřilo delegaci i jiným agentům a obešlo D6")


# Kdo smí komu delegovat. Formát: {jméno agenta: množina povolených cílů}.
# `None` = agent smí delegovat jen na to, co je v `PRIMARY_TOOL_ALLOW_EXCEPTIONS`
# (orchestrátor — D14 wildcard).
#
# `implementer` je schválený oddělený model: nesmí spouštět testy (`bash:
# deny`), ale smí si je vyžádat u `tester` (`implementer.md:8`). Bez tohoto
# záznamu by kontrola nahlásila druhého delegátora a právě popřela zamýšlené
# oddělení autora od kontrolora.
AGENT_DELEGATION_ALLOWLIST = {
    "orchestrator": None,   # D14: {"*": "allow"}
    "implementer": {"tester"},
}


def test_only_orchestrator_may_delegate():
    """Delegovat smí POUZE dvojice z `AGENT_DELEGATION_ALLOWLIST`.

    Odůvodnění: D6 / invariant 3 — žádná paralelní orchestration cesta.
    Kdyby mohl delegovat kdokoli, existovaly by dva delegátory a dva
    řetězce; tato kontrola je přesně ta, která to odmítne.

    Výjimka pro `implementer` (schválena vlastníkem 2026-10-02) je omezená na
    JEDINÝ cíl `tester` a navázaná na funkci, ne na `mode`. Kdyby někdo
    přidal `implementer` → jiný cíl, nebo povolil delegaci třetímu agentovi,
    tato kontrola SELHÁ — nesmí se zjednodušit na `if path.stem in {...}`.
    """
    for path in _agent_files():
        task = _agent_permission(path).get("task") or {}
        allows = {k for k, v in task.items() if v == "allow"}
        if path.stem not in AGENT_DELEGATION_ALLOWLIST:
            assert not allows, (
                f"{path.stem}: nesmí delegovat vůbec (D6 — jediný delegátor "
                f"je orchestrator), nalezené allow: {sorted(allows)}")
            continue
        allowed_targets = AGENT_DELEGATION_ALLOWLIST[path.stem]
        if allowed_targets is None:
            assert allows, (
                "orchestrator musí mít alespoň jedno task: allow (D14)")
            continue
        # `*` by tady znamenalo neomezenou delegaci — nesmí být povoleno
        # nikomu, kdo není orchestrátor.
        assert "*" not in allows, (
            f"{path.stem}: wildcard '*' je výhradně výjimka D14 pro "
            f"orchestrátora")
        assert allows == allowed_targets, (
            f"{path.stem}: smí delegovat POUZE {sorted(allowed_targets)}, "
            f"nalezené allow: {sorted(allows)}")


def test_allowlisted_agents_that_exist_are_subagents():
    """Každý agent z orchestrátorova allowlistu, který už má `.md`, musí mít
    `mode: subagent`.

    Odůvodnění: invariant 3 + 12 — primary agent spuštěný jako subagent by
    obejel hloubku delegace 1 (dostal by vlastní `task` pravidla) a
    zmnožil by orchestration path. KONTROLA SE VZTAHUJE JEN NA AGENCY,
    KTEŘÍ ONAČD EXISTUJÍ — viz `test_allowlist_agents_pending_f4`.
    """
    task = _agent_permission(AGENT_DIR / "orchestrator.md").get("task") or {}
    allowlist = [k for k, v in task.items() if v == "allow"]
    assert allowlist, "orchestrator allowlist je prázdný — test by nic netestoval"

    for name in allowlist:
        path = AGENT_DIR / f"{name}.md"
        if not path.is_file():
            continue
        mode = _load_agent(path).get("mode")
        assert mode == "subagent", (
            f"{name}.md je v orchestrátorovu allowlistu, ale mode={mode!r} "
            "(musí být 'subagent')")


def test_allowlist_agents_pending_f4(capsys):
    """NEFAILUJÍCÍ informační test: vypíše agenty z allowlistu bez `.md`.

    Odůvodnění: v F1 existují jen tři agenti, zbývající vzniknou až ve F4.
    Chybějící `.md` NENÍ chyba F1 — Gate 0 nesmí blokovat postup projektu
    kvůli tomu, že F4 ještě nenastal. Test jen vypíše seznam doplnit, aby
    byl při F4 dohledatelný. Používá `print`, nikoli `assert` — záměrně
    nefailuje.
    """
    task = _agent_permission(AGENT_DIR / "orchestrator.md").get("task") or {}
    allowlist = [k for k, v in task.items() if v == "allow"]
    pending = [n for n in allowlist if not (AGENT_DIR / f"{n}.md").is_file()]

    with capsys.disabled():
        print("\n[Gate 0/F1] Agenti z orchestrátorova allowlistu bez .md (očekávané do F4):")
        if not pending:
            print("  (žádný — allowlist je plně pokrytý)")
        for name in pending:
            marker = ("očekávané do F4" if name in F4_EXPECTED_PENDING
                      else "POZOR: mimo známý seznam F4")
            print(f"  - {name} ({marker})")
        print(f"[Gate 0/F1] pokryto: {len(allowlist) - len(pending)}/{len(allowlist)}; celkem .md agentů v .kilo/agent/: {len(_agent_files())}")


# ==========================================================================
# E. Fail-closed, ne tichý fallback
# ==========================================================================

def test_mcp_server_explicitly_disabled():
    """`mcp.cyberbrain26 == {"enabled": false}`.

    Odůvodnění: invariant 1 a 2 (§14.1, §14.2) — fail-closed. MCP server
    v F1 v repu neexistuje (ověřeno: `src/cyberbrain26/mcp/` neexistuje,
    `mcp` SDK není v `pyproject.toml`), proto musí být explicitně vypnutý.
    Chybějící klíč by znamenal, že stav závisí na výchozí hodnotě Kilo,
    tedy tichý přechod.
    """
    mcp = _load_global().get("mcp")
    assert isinstance(mcp, dict), f"mcp={mcp!r} není objekt"
    assert mcp.get("cyberbrain26") == {"enabled": False}, (
        "mcp.cyberbrain26={!r}, očekáváno {{'enabled': False}}".format(
            mcp.get("cyberbrain26")))


def test_no_invalid_top_level_keys():
    """Konfigurace nesmí obsahovat `permissions`, `features` ani top-level
    `mode` jako řetězec.

    Odůvodnění: invariant 1 (žádný tichý přechod) — `Config` má
    `additionalProperties: false`, takže CLI by takovou konfiguraci odmítlo;
    případné tiché ignorování neznámého klíče by znamenalo, že autor pravidla
    se mýlí, ale test mlčel. `mode` na top-level je objekt označený
    `@deprecated` — řetězec tam nepatří.
    """
    cfg = _load_global()
    for bad in ("permissions", "features"):
        assert bad not in cfg, f"neplatný top-level klíč: {bad!r}"
    assert "mode" not in cfg or isinstance(cfg["mode"], dict), (
        "top-level 'mode' jako řetězec je neplatný (schema má objekt @deprecated)")


# ==========================================================================
# F. Dědičnost — unit testy mechanismus, na kterém stojí kontrola C
# ==========================================================================

def test_resolve_agent_value_wins():
    """(a) Agent má hodnotu → vyhrává agent, globál se nepoužije."""
    assert _resolve("bash", {"bash": "ask"}, {"bash": "deny"}) == "ask"


def test_resolve_inherits_from_global_when_agent_missing():
    """(b) Klíč v agentovi chybí → zdědí se z globálu."""
    assert _resolve("edit", {"bash": "deny"}, {"edit": "deny"}) == "deny"


def test_resolve_returns_none_when_missing_in_both():
    """(c) Chybí v obou → `None` = nic není nastaveno (fail), ne výchozí
    hodnota zachycená tichou dědičností."""
    assert _resolve("bash", {"edit": "deny"}, {"edit": "deny"}) is None


def test_resolve_agent_bash_dict_without_star_falls_back_to_global():
    """(d) `bash` jako dict v agentovi `{...}` s chybějícím `"*"` padá na
    globál — `"*"` je catch-all a bez něj dict není úplný, takže nesmí
    přepsat globální deny. Efektivní hodnota je zde globální deny.
    """
    effective = _resolve("bash", {"bash": {"git": "ask"}}, {"bash": "deny"})
    assert effective == "deny"


# ==========================================================================
# G. Meta-test — kontrola MUSÍ být schopna selhat
# ==========================================================================

_GLOBAL_DENY_BASELINE = {
    "*": "ask",
    "bash": "deny",
    "external_directory": "deny",
    "agent_manager": "deny",
    "doom_loop": "deny",
    "task": {"*": "deny"},
}


def test_detector_rejects_bash_allow(tmp_path):
    """Meta-test: falešný agent s `bash: allow` musí být odmítnut.

    Odůvodnění: bráníme Gate 0 před tichým průchodem. Kdyby kontrolní logika
    nedělala nic, všechny ostatní testy by byly zelené bez jakékoli pojistky.
    `tmp_path` zajišťuje, že se do repa nezapisuje.
    """
    agent_perm = dict(_GLOBAL_DENY_BASELINE)
    agent_perm["bash"] = "allow"
    md = _write_fake_agent(tmp_path, "fake-allower", "subagent", agent_perm)
    assert md.is_file(), "meta-test fixtures musí být zapisovány jen do tmp_path"

    violations = _violations("bash", agent_perm, _GLOBAL_DENY_BASELINE)
    assert violations, "detekce bash: allow MUSÍ selhat (jinak Gate 0 nic nekontroluje)"
    assert any("'allow'" in v for v in violations), violations

    # Stejná logika na skutečném `.md` z tmp_path: frontmatter se načte a porušení
    # je vidět — kontrola tedy není „napevno zapsaná pravda".
    reloaded = yaml.safe_load(md.read_text(encoding="utf-8").split("---", 2)[1])
    assert reloaded["permission"]["bash"] == "allow"
    assert _violations("bash", reloaded["permission"], _GLOBAL_DENY_BASELINE)


def test_detector_rejects_missing_bash(tmp_path):
    """Meta-test: agent s chybějícím `bash` (tichá dědičnost) musí být odmítnut.

    Odůvodnění: totéž co výše, ale pro druhý způsob selhání — klíč `bash`
    ve frontmatteru vůbec není, takže by se spoléhalo na dědičnost z globálu.
    Dva nezávislé zápisy jsou záměr, ale chybějící zápis musí být viditelný už
    v Gate 0, ne až při auditu.
    """
    agent_perm = dict(_GLOBAL_DENY_BASELINE)
    del agent_perm["bash"]
    md = _write_fake_agent(tmp_path, "fake-silent", "subagent", agent_perm)
    assert md.is_file()

    violations = _violations("bash", agent_perm, _GLOBAL_DENY_BASELINE)
    assert any("not explicitly denied" in v for v in violations), violations
    # Hodnota se sice zdědí z globálu (a je tedy deny), ale tichá dědičnost
    # je přesně to, co Gate 0 musí odmítnout — proto je porušení i bez změny
    # efektivní hodnoty.
    assert _resolve("bash", agent_perm, _GLOBAL_DENY_BASELINE) == "deny"

    reloaded = yaml.safe_load(md.read_text(encoding="utf-8").split("---", 2)[1])
    assert "bash" not in reloaded["permission"], "fixture musí opravdu postrádat bash"
