Implementeaza planul claude-master-plan-discord-bridge-20260830 (15 taskuri, 3 lane-uri paralele) — un bot subtire discord.py peste CLI-ul `claude`, cu proces persistent per fir alimentat pe stdin cu --input-format stream-json. Nucleu: runner (proces persistent + reaper 20min + respawn --resume), stream (parser tolerant), session_store (scriere atomica, lock per fir, detectare PID reuse, recovery), limits (max 4 procese, timeout tur, rate per user, plafon cost pe zi), render (un loop de editare per canal, interval adaptiv). Adaptor: allowlist guild/canal/user fail-closed cu respingerea webhook-urilor, comenzi !new/!cd/!model/!status/!stop/!cleanup, cost si model in subsolul fiecarui raspuns. Mesajul sosit in timpul unui tur devine steering, nu tur nou. Securitate: hook PreToolUse fail-closed care cere confirmare in Discord pentru operatiuni ireversibile, wrapper `infra` cu lista explicita de hosturi. Deny rules raman strat cosmetic, nu bariera (verificat: /usr/bin/ssh trece pe langa). Ops: alerte email pe conventia repo-ului, !cleanup pentru orfani, unit systemd user cu KillMode=control-group si limite de memorie, install.sh idempotent. Verificat: 275 teste fara retea/Discord/API (10.8s), identic cu si fara discord.py instalat; e2e pe CLI real confirma steering-ul mid-tur (mesaj la 6s intr-un tool call de 25s schimba raspunsul final). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
462 lines
16 KiB
Python
462 lines
16 KiB
Python
#!/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())
|