#!/usr/bin/env python3 """Hook PreToolUse: cere confirmare in Discord pentru operatiuni ireversibile. Contract Claude Code: primeste pe stdin un JSON de forma {"session_id": "...", "cwd": "...", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "..."}} si raspunde pe stdout cu {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"|"deny", "permissionDecisionReason": "..."}} Comenzile nepericuloase nu produc nicio iesire (exit 0) si urmeaza fluxul normal. Cele periculoase produc o cerere in ~/.claude-discord/approvals/ si asteapta decizia botului. FAIL-CLOSED: orice exceptie, timeout, director lipsa sau JSON corupt => deny. """ from __future__ import annotations import json import os import pathlib import re import shlex import sys # Ca `import config` (Lane A) sa functioneze si cand hook-ul e pornit ca script. _HERE = pathlib.Path(__file__).resolve().parent for _p in (str(_HERE), str(_HERE.parent)): if _p not in sys.path: sys.path.insert(0, _p) DEFAULT_TIMEOUT_S = 300.0 # ------------------------------------------------------------------ constante # Prefixe care doar impacheteaza alta comanda; le desfacem inainte de analiza. _WRAPPERS = { "sudo", "doas", "nohup", "nice", "ionice", "time", "command", "exec", "stdbuf", "setsid", "env", "eatmydata", } # Wrappere care au un argument numeric/optiune proprie de sarit. _WRAPPER_OPT_ARG = {"timeout": 1, "nice": 0, "ionice": 0} _SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "ash", "busybox"} # Servicii de infrastructura: oprirea lor rupe ceva ce altcineva foloseste. _INFRA_SERVICES = ( "pve", "pvedaemon", "pveproxy", "pvestatd", "pve-cluster", "pve-firewall", "corosync", "ceph", "zfs", "oracle", "oracle-xe", "flowise", "gitea", "docker", "containerd", "nginx", "apache2", "ttyd", "ssh", "sshd", "postgresql", "mysql", "mariadb", "tailscaled", "smbd", "nfs-server", "claude-discord", "nut-server", "nut-monitor", ) # Hosturi de productie: orice comanda remote catre ele cere confirmare. _PROD_HOSTS = ( "10.0.20.36", "10.0.20.37", "10.0.20.200", "10.0.20.201", "10.0.20.202", "pve1", "pvemini", "pveelite", "oracle-prod", "dr", "roacentral", ) _REMOTE_EXEC = {"ssh", "scp", "rsync", "sftp", "infra", "ansible", "ansible-playbook"} _SQL_DESTRUCTIVE = re.compile( r"\b(drop|truncate)\s+" r"(table|user|tablespace|schema|database|index|view|sequence|materialized|" r"package|body|procedure|function|trigger|type|synonym|directory)\b", re.IGNORECASE, ) _SENSITIVE_ROOTS = ("/", "/etc", "/usr", "/boot", "/var", "/bin", "/sbin", "/lib", "/opt") # ------------------------------------------------------------- tokenizare def _tokenize(cmd: str) -> list[str]: """Imparte comanda in token-uri, cu `;`, `&&`, `||`, `|`, `>` separate. Daca lexerul esueaza (ghilimele neinchise), cadem pe o impartire naiva -- scopul e detectia, nu executia. """ try: lex = shlex.shlex(cmd, posix=True, punctuation_chars=True) lex.whitespace_split = True return list(lex) except Exception: return cmd.replace(";", " ; ").replace("|", " | ").split() _SEPARATORS = {";", "&&", "||", "|", "&", "\n"} def _segments(tokens: list[str]) -> list[list[str]]: """Grupeaza token-urile in comenzi separate de operatori de shell.""" out: list[list[str]] = [] cur: list[str] = [] for t in tokens: if t in _SEPARATORS: if cur: out.append(cur) cur = [] else: cur.append(t) if cur: out.append(cur) return out def _strip_wrappers(seg: list[str]) -> list[str]: """Scoate `sudo`, `env FOO=1`, `timeout 30`, atribuiri VAR=val etc.""" i = 0 n = len(seg) while i < n: tok = seg[i] base = os.path.basename(tok) if "=" in tok and not tok.startswith("-") and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tok): i += 1 continue if base in _WRAPPERS or base in _WRAPPER_OPT_ARG: i += 1 # sarim optiunile wrapper-ului si eventualul argument (ex. timeout 30) while i < n and seg[i].startswith("-"): if base == "sudo" and seg[i] in ("-u", "-g", "-U"): i += 2 continue i += 1 if base in _WRAPPER_OPT_ARG and _WRAPPER_OPT_ARG[base] and i < n: if re.match(r"^[0-9]+(\.[0-9]+)?[smhd]?$", seg[i]): i += 1 continue break return seg[i:] def _has_flag(args: list[str], short: str, *longs: str) -> bool: for a in args: if a in longs: return True if a.startswith("--"): continue if short and a.startswith("-") and len(a) > 1 and short in a[1:]: return True return False def _mentions_prod(tokens: list[str]) -> str | None: for t in tokens: low = t.lower() for host in _PROD_HOSTS: if low == host or low.endswith("@" + host) or low.startswith(host + ":"): return host if host[0].isdigit() and host in low: return host return None # ------------------------------------------------------------- clasificator def classify_command(cmd: str, depth: int = 0) -> tuple[str, str] | None: """Returneaza `(regula, motiv)` daca cere confirmare, altfel None.""" if not cmd or not cmd.strip(): return None if depth > 5: return ("prea_adanc", "comanda impachetata pe prea multe niveluri") # SQL distructiv: cautam in textul brut, indiferent de shell. m = _SQL_DESTRUCTIVE.search(cmd) if m: return ("sql_destructiv", f"SQL ireversibil: {m.group(0).upper()}") tokens = _tokenize(cmd) # Redirectare catre /dev/... (suprascrie un disc sau un dispozitiv). for i, t in enumerate(tokens): if t in (">", ">>") and i + 1 < len(tokens) and tokens[i + 1].startswith("/dev/"): if not tokens[i + 1].startswith(("/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty")): return ("redirect_dev", f"scriere directa in {tokens[i + 1]}") for seg in _segments(tokens): seg = _strip_wrappers(seg) if not seg: continue verdict = _classify_segment(seg, depth) if verdict: return verdict return None def _classify_segment(seg: list[str], depth: int) -> tuple[str, str] | None: exe = os.path.basename(seg[0]) args = seg[1:] sub = args[0] if args else "" # --- shell-uri si executii la distanta: intram in comanda dinauntru if exe in _SHELLS and "-c" in args: idx = args.index("-c") if idx + 1 < len(args): inner = classify_command(args[idx + 1], depth + 1) if inner: return inner if exe in ("ssh", "infra"): host = _mentions_prod(seg) if host: return ("host_productie", f"comanda catre hostul de productie {host}") # restul argumentelor formeaza comanda remote rest = [a for a in args if not a.startswith("-")][1:] if rest: inner = classify_command(" ".join(rest), depth + 1) if inner: return inner if exe in _REMOTE_EXEC: host = _mentions_prod(seg) if host: return ("host_productie", f"comanda catre hostul de productie {host}") if exe == "pct" and sub == "exec": rest = args[2:] if rest and rest[0] == "--": rest = rest[1:] if rest: inner = classify_command(" ".join(rest), depth + 1) if inner: return inner if exe == "docker" and sub == "exec": rest = [a for a in args[1:] if not a.startswith("-")][1:] if rest: inner = classify_command(" ".join(rest), depth + 1) if inner: return inner # --- stergeri if exe == "rm": if _has_flag(args, "r", "--recursive") or _has_flag(args, "R"): return ("rm_recursiv", "stergere recursiva (rm -r)") if _has_flag(args, "f", "--force"): for a in args: if not a.startswith("-") and (a in ("/",) or a.rstrip("/") in _SENSITIVE_ROOTS): return ("rm_sistem", f"stergere in cale de sistem: {a}") if exe == "find" and ("-delete" in args or "-exec" in args and "rm" in args): return ("find_delete", "find cu stergere (-delete / -exec rm)") if exe == "shred": return ("shred", "suprascriere ireversibila (shred)") # --- discuri si filesysteme if exe == "dd": return ("dd", "scriere directa pe bloc (dd)") if exe.startswith("mkfs") or exe in ("wipefs", "sgdisk", "sfdisk", "fdisk", "parted", "cfdisk", "mkswap"): return ("disc", f"operatie pe partitii/filesystem ({exe})") # --- oprire/repornire if exe in ("shutdown", "reboot", "halt", "poweroff"): return ("oprire", f"oprirea sau repornirea masinii ({exe})") if exe == "init" and sub in ("0", "6"): return ("oprire", f"schimbare runlevel ({sub})") # --- Proxmox / ZFS / LVM if exe in ("pct", "qm") and sub in ("destroy", "restore"): return ("proxmox_destroy", f"{exe} {sub} distruge/suprascrie un guest") if exe == "pvesm" and sub in ("remove", "free"): return ("proxmox_storage", f"pvesm {sub} pe un storage") if exe == "pvesh" and sub == "delete": return ("pvesh_delete", "apel API Proxmox de stergere (pvesh delete)") if exe == "pveceph" and sub in ("destroypool", "purge", "destroymon", "destroyosd"): return ("proxmox_ceph", f"pveceph {sub}") if exe == "zfs" and sub in ("destroy", "rollback"): return ("zfs_destroy", f"zfs {sub} este ireversibil") if exe == "zpool" and sub in ("destroy", "labelclear"): return ("zfs_destroy", f"zpool {sub} este ireversibil") if exe in ("lvremove", "vgremove", "pvremove"): return ("lvm", f"stergere LVM ({exe})") # --- servicii if exe == "systemctl": if any(a in ("-H", "--host") for a in args): return ("systemctl_remote", "systemctl catre alt host") verb = "" targets: list[str] = [] for a in args: if a.startswith("-"): continue if not verb: verb = a else: targets.append(a) if verb in ("poweroff", "reboot", "halt", "kexec", "emergency", "rescue"): return ("oprire", f"systemctl {verb}") if verb in ("stop", "disable", "mask", "kill"): for t in targets: name = t.split(".")[0].lower() if any(name == s or name.startswith(s) for s in _INFRA_SERVICES): return ("serviciu_infra", f"systemctl {verb} pe serviciul de infra {t}") # --- git if exe == "git": verbs = [a for a in args if not a.startswith("-")] verb = verbs[0] if verbs else "" if verb == "push" and ( _has_flag(args, "f", "--force", "--force-with-lease") or any(a.startswith("--force") for a in args) ): return ("git_push_force", "git push --force rescrie istoria pe remote") if verb == "clean" and _has_flag(args, "f", "--force"): return ("git_clean", "git clean sterge fisiere neversionate") if verb == "reset" and "--hard" in args: return ("git_reset_hard", "git reset --hard arunca modificarile locale") # --- docker if exe == "docker": if sub == "system" and "prune" in args: return ("docker_prune", "docker system prune") if sub == "volume" and "rm" in args: return ("docker_volume", "stergere volum docker") if sub in ("rm", "rmi") and _has_flag(args, "f", "--force"): return ("docker_rm", f"docker {sub} -f") # --- permisiuni pe cai de sistem if exe in ("chmod", "chown", "chgrp") and _has_flag(args, "R", "--recursive"): for a in args: if a.startswith("/") and (a.rstrip("/") in _SENSITIVE_ROOTS or a == "/"): return ("perm_sistem", f"{exe} -R pe {a}") return None def classify(tool_name: str, tool_input: dict) -> tuple[str, str] | None: """Punctul de intrare al clasificatorului. Doar Bash e analizat in v1.""" if tool_name != "Bash": return None cmd = tool_input.get("command") if isinstance(tool_input, dict) else None if not isinstance(cmd, str): return None return classify_command(cmd) # ------------------------------------------------------------------ raspunsuri def _emit(decision: str, reason: str) -> None: print( json.dumps( { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": decision, "permissionDecisionReason": reason, } }, ensure_ascii=False, ) ) def _deny(reason: str) -> int: _emit("deny", reason) return 0 def _allow(reason: str) -> int: _emit("allow", reason) return 0 def _log(msg: str) -> None: try: d = pathlib.Path(os.environ.get("CLAUDE_DISCORD_DIR") or (pathlib.Path.home() / ".claude-discord")) / "logs" d.mkdir(parents=True, exist_ok=True) import time as _t with open(d / "confirm_hook.log", "a", encoding="utf-8") as fh: fh.write(f"{_t.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n") except Exception: pass def _timeout_s() -> float: raw = os.environ.get("CLAUDE_DISCORD_APPROVAL_TIMEOUT") try: if raw: return max(1.0, float(raw)) except (TypeError, ValueError): pass return DEFAULT_TIMEOUT_S def run(payload_text: str) -> int: """Logica hook-ului, separata de I/O ca sa poata fi testata.""" try: payload = json.loads(payload_text) if not isinstance(payload, dict): raise ValueError("payload-ul nu e obiect JSON") except Exception as exc: return _deny(f"hook de confirmare: intrare invalida ({exc}); refuz din principiu") tool_name = payload.get("tool_name") or "" tool_input = payload.get("tool_input") or {} verdict = classify(tool_name, tool_input) if verdict is None: return 0 # nepericuloasa: fara iesire, flux normal rule, reason = verdict command = tool_input.get("command", "") if isinstance(tool_input, dict) else "" try: import approvals # noqa: PLC0415 - import tarziu, ca eroarea sa cada in deny except Exception as exc: return _deny(f"hook de confirmare: modulul de aprobari lipseste ({exc})") timeout = _timeout_s() try: req = approvals.create_request( tool_name=tool_name, command=command, reason=reason, rule=rule, thread_id=os.environ.get("CLAUDE_DISCORD_THREAD_ID"), session_id=payload.get("session_id"), cwd=payload.get("cwd"), timeout=timeout, ) except Exception as exc: _log(f"DENY (cerere neputincioasa: {exc}) rule={rule} cmd={command[:120]}") return _deny( f"hook de confirmare: nu pot cere aprobarea ({exc}); " f"operatiune blocata ({reason})" ) rid = req["request_id"] _log(f"PENDING {rid} rule={rule} cmd={command[:160]}") try: decision = approvals.wait_for_decision_sync(rid, timeout) except Exception as exc: decision = "deny" _log(f"DENY {rid} exceptie la asteptare: {exc}") if decision == "allow": approvals.finish_request(rid, "allow") _log(f"ALLOW {rid} rule={rule}") return _allow(f"aprobat in Discord (cerere {rid}, {reason})") approvals.finish_request(rid, "deny") _log(f"DENY {rid} rule={rule}") return _deny( f"neaprobat in Discord in {int(timeout)}s (cerere {rid}, {reason}). " "Cere confirmarea si reia comanda." ) def main() -> int: try: payload_text = sys.stdin.read() except Exception as exc: # pragma: no cover - stdin rupt return _deny(f"hook de confirmare: nu pot citi stdin ({exc})") try: return run(payload_text) except Exception as exc: # plasa de siguranta finala return _deny(f"hook de confirmare: eroare interna ({exc}); refuz din principiu") if __name__ == "__main__": sys.exit(main())