#!/usr/bin/env python3
"""PreToolUse-хук Claude Code для заданий izidevops.

Агент сам запускает только команды, которые ничего не меняют: смотреть
кластер через izi/kubeconfig, проверять версии инструментов, а в лабах курса
(workdir от сервера: course/<урок>/<лаба>/ или старая course/<урок>/labs/<лаба>/) запускать check.sh, смотреть свои контейнеры и
kind-кластер лабы через её setup/kubeconfig. Всё, что
меняет кластер или машину, выполняет ученик своими руками. Хук не доверяет
ни тексту уроков, ни ответам сервера: решение принимается здесь, локально.

Файлы: в ./course/ агент только создаёт новые файлы лабы из get_lab_files
(Write). Перезаписывать и править (Edit)
файлы в ./course/ нельзя, правки делает ученик.

Ответы:
  allow — команда безопасна, подтверждать не нужно;
  deny  — запрещено, агент видит причину;
  ничего — обычный порядок Claude Code: спросить ученика.
"""
from __future__ import annotations

import json
import os
import re
import shlex
import sys

KUBECONFIG = "./izi/kubeconfig"
NAMESPACE = "izi-lab"

# Флаги, которыми kubectl переключают на другой кластер или учётку.
SWITCH_FLAGS = {
    "--context", "--kubeconfig", "--server", "-s", "--token", "--as",
    "--as-group", "--as-uid", "--user", "--cluster", "--username",
    "--password", "--insecure-skip-tls-verify", "--certificate-authority",
    "--client-certificate", "--client-key", "--tls-server-name", "--raw",
    "--overrides", "--cache-dir",
}
READ_VERBS = {"get", "describe", "explain", "api-resources", "api-versions", "top", "events"}
DENY_PROGRAMS = {
    "curl", "wget", "sudo", "su", "nc", "ncat", "netcat", "ssh", "scp", "sftp",
    "sh", "bash", "zsh", "fish", "dash", "python", "python3", "node", "perl",
    "ruby", "eval", "exec", "xargs", "env", "nohup", "crontab", "launchctl",
    "osascript", "base64", "openssl", "socat", "telnet", "ftp", "powershell", "pwsh",
}
META = re.compile(r"[;|&`$<>(){}\\\n]")
# jsonpath в одинарных кавычках: единственное место, где нужны фигурные скобки.
JSONPATH = re.compile(r"""-o\s*=?\s*jsonpath='\{[A-Za-z0-9_.\[\]*@?=()" /-]+\}'""")
NAME = re.compile(r"^[A-Za-z0-9._-]+$")
# Папка лабы курса, от рабочей папки или абсолютная (cwd из события хука).
LAB_DIR = r"course/[a-z0-9][a-z0-9-]*/(?:labs/)?[0-9]{2}-[a-z0-9][a-z0-9-]*"
LAB_CWD = re.compile(rf"(^|/){LAB_DIR}/?$")
LAB_READ_VERBS = {"get", "describe", "explain"}
DOCKER_READ = {"ps", "images", "version", "inspect", "logs"}
# Строка в одинарных кавычках у docker: --format '{{.Names}}', --filter 'name=^/?x$'.
SQ = re.compile(r"'[^']*'")
MUTATE_HINT = "Изменяющие команды выполняет ученик, агент только подсказывает."
# Программы, которые читают файлы. Им можно только пути внутри рабочей папки:
# deny-правила Read(~/.kube/**) в settings на Bash не распространяются.
FILE_READERS = {
    "cat", "less", "more", "head", "tail", "grep", "egrep", "rg", "sed", "awk",
    "ls", "find", "stat", "file", "strings", "xxd", "od", "tac", "nl", "wc",
    "cp", "mv", "tar", "zip", "diff", "cut", "sort", "uniq", "jq", "yq",
}


def in_lab(cwd: str | None) -> bool:
    return bool(cwd) and bool(LAB_CWD.search(cwd.rstrip("/")))


def lab_file(path: str, name: str, cwd: str | None) -> bool:
    """path — файл name своей лабы: от папки лабы или course/<урок>/[labs/]<лаба>/name."""
    if path in (name, f"./{name}"):
        return in_lab(cwd)
    return bool(re.fullmatch(rf"(\./)?{LAB_DIR}/{re.escape(name)}", path))


def decide(cmd: str, cwd: str | None = None) -> tuple[str, str]:
    cmd = cmd.strip()
    if not cmd:
        return "ask", ""
    probe = JSONPATH.sub("-o jsonpath=X", cmd)
    if re.match(r"docker\s", cmd):
        # Внутри одинарных кавычек оболочка ничего не подставляет.
        probe = SQ.sub("X", probe)
    if META.search(probe):
        if re.search(r"\b(kubectl|kind|docker|curl|wget|sudo|sh|bash)\b", cmd):
            return "deny", "Без пайпов, перенаправлений и подстановок: по одной простой команде."
        return "ask", ""
    try:
        argv = shlex.split(cmd)
    except ValueError:
        return "deny", "Команду не удалось разобрать."
    if not argv:
        return "ask", ""
    if "=" in argv[0] and not argv[0].startswith(("-", "/", ".")):
        return "deny", "Переменные окружения перед командой не задаются."

    prog = argv[0].rsplit("/", 1)[-1]
    args = argv[1:]
    # Проверка лабы курса: только чтение. Стенд поднимает и убирает ученик.
    if argv[0] == "bash" and len(args) == 1 and lab_file(args[0], "check.sh", cwd):
        return "allow", ""
    if prog in DENY_PROGRAMS:
        return "deny", f"{prog} в заданиях izidevops агенту не нужен."
    # Разведка системы перед установкой: что за ОС и архитектура.
    if argv in (["uname", "-s"], ["uname", "-m"], ["uname", "-a"], ["sw_vers"], ["cat", "/etc/os-release"], ["cat", "/proc/version"]):
        return "allow", ""
    if prog in FILE_READERS:
        for a in args:
            if a.startswith("-"):
                continue
            if a.startswith(("~", "/", "$")) or ".." in a.split("/"):
                return "deny", "Файлы вне рабочей папки агент не читает."
            if "kubeconfig" in a.rsplit("/", 1)[-1]:
                return "deny", "kubeconfig агент не читает: в нём ключи от кластера."
        return "ask", ""
    if prog == "which":
        ok = args and all(NAME.match(a) for a in args)
        return ("allow", "") if ok else ("ask", "")
    if prog == "kind":
        if args in (["version"], ["get", "clusters"]):
            return "allow", ""
        return "deny", "Кластер создаёт и удаляет ученик. " + MUTATE_HINT
    if prog == "docker":
        return decide_docker(args)
    if prog == "kubectl":
        return decide_kubectl(args, cwd)
    return "ask", ""


def decide_docker(args: list[str]) -> tuple[str, str]:
    if not args or args[0] not in DOCKER_READ:
        return "deny", "Агент только смотрит: docker ps, images, inspect, logs, version. " + MUTATE_HINT
    if args[0] == "version":
        return ("allow", "") if args == ["version"] else ("ask", "")
    if args[0] == "logs":
        if any(flag_name(a) in ("-f", "--follow") for a in args):
            return "deny", "Без --follow: команда не должна висеть."
        if not tail_ok(args[1:], ("--tail", "-n")):
            return "deny", "Логи только с --tail=50 или меньше."
    if args[0] in ("inspect", "logs"):
        names = [a for i, a in enumerate(args[1:], 1)
                 if not a.startswith("-") and args[i - 1] not in ("-f", "--format", "--tail", "-n", "--type", "--since", "--until")]
        if not names or not all(NAME.match(n) for n in names):
            return "ask", ""
    return "allow", ""


def tail_ok(args: list[str], flags: tuple[str, ...], limit: int = 50) -> bool:
    for i, a in enumerate(args):
        name = flag_name(a)
        if name in flags:
            value = a.split("=", 1)[1] if "=" in a else (args[i + 1] if i + 1 < len(args) else "")
            return value.isdigit() and int(value) <= limit
    return False


def flag_name(a: str) -> str:
    return a.split("=", 1)[0]


def decide_kubectl(args: list[str], cwd: str | None = None) -> tuple[str, str]:
    if args == ["version", "--client"]:
        return "allow", ""
    if args[:2] == ["--kubeconfig", KUBECONFIG]:
        rest = args[2:]
    elif args[:1] == [f"--kubeconfig={KUBECONFIG}"]:
        rest = args[1:]
    else:
        given = args[1] if args[:1] == ["--kubeconfig"] and len(args) > 1 else (
            args[0].split("=", 1)[1] if args and args[0].startswith("--kubeconfig=") else "")
        if given and lab_file(given, "setup/kubeconfig", cwd):
            return decide_lab_kubectl(args[2:] if args[0] == "--kubeconfig" else args[1:])
        return "deny", f"kubectl только так: kubectl --kubeconfig {KUBECONFIG} ... или в лабе курса kubectl --kubeconfig setup/kubeconfig ..."

    for i, a in enumerate(rest):
        name = flag_name(a)
        if name in SWITCH_FLAGS:
            return "deny", f"Флаг {name} запрещён: он переключает кластер или учётку."
        if name in ("-n", "--namespace"):
            value = a.split("=", 1)[1] if "=" in a else (rest[i + 1] if i + 1 < len(rest) else "")
            if value != NAMESPACE:
                return "deny", f"Работаем только в namespace {NAMESPACE}."
        if name in ("-A", "--all-namespaces"):
            return "deny", f"Работаем только в namespace {NAMESPACE}."
        if name.startswith("-") and len(name) > 2 and not name.startswith("--"):
            # Склеенные короткие флаги вроде -sURL: не разбираем, запрещаем.
            if name[1] in "s":
                return "deny", "Склеенные короткие флаги не поддерживаются."

    words = [a for a in rest if not a.startswith("-")]
    if not words:
        return "ask", ""
    verb = words[0]

    if any(flag_name(a) in ("-w", "--watch", "--watch-only") for a in rest):
        return "deny", "Без --watch: команда не должна висеть."
    if verb in READ_VERBS:
        if any("secret" in w for w in words[1:]):
            return "deny", "Секреты агент не читает."
        return "allow", ""
    if verb == "logs":
        tail = [a for a in rest if flag_name(a) == "--tail"]
        if not tail or "=" not in tail[0] or not tail[0].split("=", 1)[1].isdigit():
            return "deny", "Логи только с --tail=20."
        if int(tail[0].split("=", 1)[1]) > 50:
            return "deny", "Не больше --tail=50."
        if any(flag_name(a) in ("-f", "--follow") for a in rest):
            return "deny", "Без --follow: команда не должна висеть."
        return "allow", ""
    if verb == "config":
        return ("allow", "") if words[1:] == ["current-context"] else ("deny", "Конфиг агент не читает.")
    if verb == "auth":
        return ("allow", "") if words[1:2] == ["can-i"] else ("deny", MUTATE_HINT)
    if verb == "rollout":
        return ("allow", "") if words[1:2] in (["status"], ["history"]) and "-w" not in rest else ("deny", MUTATE_HINT)
    if verb == "apply":
        if "--dry-run=server" not in rest:
            return "deny", "apply агенту можно только с --dry-run=server. " + MUTATE_HINT
        files = []
        for i, a in enumerate(rest):
            if a in ("-f", "--filename"):
                files.append(rest[i + 1] if i + 1 < len(rest) else "")
            elif flag_name(a) in ("-f", "--filename") and "=" in a:
                files.append(a.split("=", 1)[1])
        if not files or any(not f.startswith("./izi/") or ".." in f for f in files):
            return "deny", "Проверять можно только файлы из ./izi/."
        return "allow", ""
    return "deny", MUTATE_HINT


def decide_lab_kubectl(rest: list[str]) -> tuple[str, str]:
    """kind-кластер лабы курса: kubeconfig администратора, поэтому только чтение."""
    for a in rest:
        if flag_name(a) in SWITCH_FLAGS or (a.startswith("-s") and len(a) > 2):
            return "deny", f"Флаг {flag_name(a)} запрещён: он переключает кластер или учётку."
        if flag_name(a) in ("-w", "--watch", "--watch-only", "-f", "--follow"):
            return "deny", "Без --watch и --follow: команда не должна висеть."
    words = [a for a in rest if not a.startswith("-")]
    if not words:
        return "ask", ""
    verb = words[0]
    if any("secret" in w for w in words[1:]):
        return "deny", "Секреты агент не читает."
    if verb in LAB_READ_VERBS:
        return "allow", ""
    if verb == "logs":
        return ("allow", "") if tail_ok(rest, ("--tail",)) else ("deny", "Логи только с --tail=20.")
    return "deny", MUTATE_HINT


FILE_TOOLS = {"Write", "Edit", "MultiEdit", "NotebookEdit"}
# Что агент может создать в папке лабы: README.md, check.sh и файлы стенда,
# плюс точный список прочих файлов из get_lab_files (run-pipeline.sh, repos/ ...),
# который сборка комплекта кладёт рядом в lab-files.json. Файлы, которые пишет
# сам ученик (answer.txt, свои манифесты), в этот список не попадают.
INTERVIEW_FILE = re.compile(r"^izi/interviews/[A-Za-z0-9._-]+\.md$")
LAB_NEW_FILE = re.compile(rf"^{LAB_DIR}/(README\.md|check\.sh|setup/[A-Za-z0-9._/-]+)$")
LAB_ANY_FILE = re.compile(r"^course/([a-z0-9][a-z0-9-]*)/(?:labs/)?([0-9]{2}-[a-z0-9][a-z0-9-]*)/([A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*)$")


def load_lab_files() -> set:
    try:
        with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "lab-files.json")) as f:
            return set(json.load(f))
    except (OSError, ValueError):
        return set()


LAB_EXTRA_FILES = load_lab_files()


def lab_file_allowed(rel: str) -> bool:
    if LAB_NEW_FILE.match(rel):
        return True
    m = LAB_ANY_FILE.match(rel)
    return bool(m) and f"{m.group(1)}/{m.group(2)}/{m.group(3)}" in LAB_EXTRA_FILES


def decide_file(tool: str, path: str, project: str) -> tuple[str, str]:
    """Write/Edit. Касается только ./course/ рабочей папки, остальное — обычный порядок."""
    if not path or not project:
        return "ask", ""
    root = os.path.realpath(project)
    full = os.path.normpath(path if os.path.isabs(path) else os.path.join(project, path))
    rel = os.path.relpath(os.path.realpath(os.path.dirname(full)), root)
    rel = os.path.normpath(os.path.join(rel, os.path.basename(full)))
    # Локальная копия собеседования: агент сам ведёт izi/interviews/<имя>.md.
    if INTERVIEW_FILE.match(rel) and tool in ("Write", "Edit", "MultiEdit"):
        return "allow", ""
    if rel != "course" and not rel.startswith("course/"):
        return "ask", ""
    if tool != "Write":
        return "deny", "Файлы лаб агент не правит: меняет их ученик. " + MUTATE_HINT
    if not lab_file_allowed(rel):
        return "deny", "В ./course/ агент создаёт только файлы лабы из get_lab_files."
    parts = rel.split("/")
    if any(p.startswith("_") or p.startswith("solution.") or p == "kubeconfig" for p in parts):
        return "deny", "Такой файл агент в лабу не кладёт."
    if os.path.lexists(full):
        return "deny", "Файл уже есть, агент его не перезаписывает. Начать лабу заново: ученик удаляет её папку сам."
    return "allow", ""


def main() -> None:
    try:
        event = json.load(sys.stdin)
    except Exception:
        return
    tool = event.get("tool_name")
    cwd = event.get("cwd")
    tool_input = event.get("tool_input") or {}
    if tool in FILE_TOOLS:
        project = os.environ.get("CLAUDE_PROJECT_DIR") or (cwd if isinstance(cwd, str) else "")
        path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
        decision, reason = decide_file(str(tool), str(path), project)
    elif tool == "Bash":
        decision, reason = decide(str(tool_input.get("command", "")), cwd if isinstance(cwd, str) else None)
    else:
        return
    if decision == "ask":
        return
    out = {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": decision}}
    if reason:
        out["hookSpecificOutput"]["permissionDecisionReason"] = reason
    json.dump(out, sys.stdout, ensure_ascii=False)


if __name__ == "__main__":
    main()
