diff --git a/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md b/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md index c61f5fc..3f581b8 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md @@ -136,3 +136,5 @@ scrie codul care il importa oricum — se rezolva la merge — si NU crea o vari - Fara user separat `cdbot`, fara audit append-only, fara dashboard web, fara Agent SDK. - Fara reluare automata a turului pierdut (risc de dubla executie). - Model default `sonnet`; `!model opus` per fir. +- Confirmarile se pot memora **pe fir** (buton "Allow (tot firul)"), pe tiparul + `(rule, reason)`, nu pe comanda si nu pe `session_id` (acela se schimba la `--resume`). diff --git a/proxmox/lxc171-claude-agent/discord-bridge/README.md b/proxmox/lxc171-claude-agent/discord-bridge/README.md index 056f7f8..e0a5153 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/README.md @@ -61,6 +61,7 @@ Cateva alegeri care nu se vad din diagrama: | `/status` | Sesiune, director, model, cost cumulat, proces viu, ultimele linii de stderr | | `/stop` | Opreste turul in desfasurare din firul curent | | `/cleanup` | Listeaza procesele lasate in urma (rulare seaca). `/cleanup force:True` le opreste | +| `/permisiuni` | Ce s-a aprobat pentru tot firul. `/permisiuni revoca:True` sterge aprobarile | | `/help` | Lista de mai sus, in fir | Comenzile sunt **application commands** (`/`), inregistrate pe guild-urile din @@ -304,7 +305,10 @@ Ce apara efectiv: 2. **Confirmare pentru operatiuni ireversibile.** Un hook `PreToolUse` opreste comanda si posteaza butoane in fir; fara raspuns in fereastra de timp raspunsul e **deny** (fail-closed). Verificat: a blocat un `rm -rf`, a asteptat aprobarea externa 20s si a - permis apoi executia, fara timeout. + permis apoi executia, fara timeout. Butonul **Allow (tot firul)** memoreaza tiparul + `(regula, motiv)` — nu comanda — pentru firul curent, ca o sesiune care lucreaza pe + acelasi host sa nu ceara zece confirmari identice; `/new`, `/permisiuni revoca:True` + si TTL-ul de 12h il sterg. Detalii in [`security/README.md`](security/README.md). 3. **Wrapper `infra`** cu lista explicita de hosturi + token Proxmox cu ACL. Ce **nu** apara: regulile `deny` din settings. Sub `bypassPermissions` ele sunt un strat diff --git a/proxmox/lxc171-claude-agent/discord-bridge/bot.py b/proxmox/lxc171-claude-agent/discord-bridge/bot.py index a9d901f..51ec02e 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/bot.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/bot.py @@ -206,10 +206,14 @@ class Command: return " ".join(self.args) -COMMANDS = ("new", "cd", "model", "status", "stop", "cleanup", "help") +COMMANDS = ("new", "cd", "model", "status", "stop", "cleanup", "permisiuni", "help") # Optiunile slash traduse in argumentele pe care cmd_* le stiu deja. -SLASH_FLAGS = {"new": ("fork", "--fork"), "cleanup": ("force", "--force")} +SLASH_FLAGS = { + "new": ("fork", "--fork"), + "cleanup": ("force", "--force"), + "permisiuni": ("revoca", "revoca"), +} def slash_args(name: str, opts: dict) -> list[str]: @@ -403,7 +407,7 @@ class Bridge: await channel.send(text) def approval_view(self, request_id: str): - """View cu Allow/Deny. Fara discord.py (sau fara Lane B) returneaza None.""" + """View cu Allow / Allow-pe-fir / Deny. Fara discord.py returneaza None.""" if discord is None or approvals is None: return None view = discord.ui.View(timeout=None) @@ -420,6 +424,7 @@ class Bridge: return button view.add_item(_mk("Allow", "allow", discord.ButtonStyle.success)) + view.add_item(_mk("Allow (tot firul)", "allow_session", discord.ButtonStyle.primary)) view.add_item(_mk("Deny", "deny", discord.ButtonStyle.danger)) return view @@ -435,6 +440,12 @@ class Bridge: ok = bool(approvals.submit_decision(request_id, decision)) if not ok: return "⚠️ Cererea nu mai exista (expirata sau deja decisa)." + if decision == "allow_session": + return ( + f"✅ Permis de <@{user_id}> — **operatiuni de acelasi fel nu se mai " + "confirma in acest fir** (`/permisiuni` le listeaza, " + "`/permisiuni revoca` le sterge)." + ) return f"{'✅ Permis' if decision == 'allow' else '🚫 Refuzat'} de <@{user_id}>." # ------------------------------------------------------------- intrare @@ -537,6 +548,7 @@ class Bridge: "`/model model:` schimba modelul firului\n" "`/status` starea firului · `/stop` opreste turul curent\n" "`/cleanup [force:True]` procese lasate in urma\n" + "`/permisiuni [revoca:True]` ce s-a aprobat pentru tot firul\n" "Orice alt mesaj din canal pleaca la Claude ca prompt.", ) @@ -548,10 +560,16 @@ class Bridge: if not fork: fields["sid"] = None self.store.update_thread(tid, **fields) + # Sesiune noua = permisiuni noi: aprobarile "pe tot firul" nu se mostenesc. + revoked = 0 + if approvals is not None: + with contextlib.suppress(Exception): + revoked = int(approvals.clear_grants(tid)) await self.say( message.channel, - "🧵 Sesiune noua pornita din contextul celei curente (`--fork`)." - if fork else "🧵 Sesiune noua, curata.", + ("🧵 Sesiune noua pornita din contextul celei curente (`--fork`)." + if fork else "🧵 Sesiune noua, curata.") + + (f" Am revocat si {revoked} aprobare(-ri) pe fir." if revoked else ""), ) async def cmd_cd(self, message, tid: str, cmd: Command) -> None: @@ -620,6 +638,38 @@ class Bridge: lines.append("stderr (ultimele linii):\n```\n" + body + "\n```") return "\n".join(lines) + async def cmd_permisiuni(self, message, tid: str, cmd: Command) -> None: + await self.say(message.channel, self.permissions_text(tid, "revoca" in cmd.args)) + + def permissions_text(self, thread_id: str, revoke: bool = False) -> str: + """Ce s-a aprobat pe firul asta cu butonul "Allow (tot firul)".""" + if approvals is None: + return "⚠️ Modulul de aprobari nu e disponibil (Lane B)." + if revoke: + n = 0 + with contextlib.suppress(Exception): + n = int(approvals.clear_grants(thread_id)) + return ( + f"🔒 Am revocat {n} aprobare(-ri) pe fir. Urmatoarele operatiuni " + "periculoase cer din nou confirmare." + if n + else "Nu era nimic de revocat pe firul asta." + ) + grants = [] + with contextlib.suppress(Exception): + grants = approvals.list_grants(thread_id) + if not grants: + return ( + "Nicio aprobare memorata pe firul asta: fiecare operatiune " + "periculoasa se confirma individual." + ) + lines = ["**Aprobate pentru firul curent**", "```"] + for g in grants: + age = max(0, int((time.time() - float(g.get("granted_at") or 0)) // 60)) + lines.append(f"{g.get('rule', '?'):<18} {g.get('reason', '')} (acum {age} min)") + lines += ["```", "`/permisiuni revoca` le sterge pe toate."] + return "\n".join(lines) + async def cmd_stop(self, message, tid: str, cmd: Command) -> None: proc = self.runner.procs.get(tid) if proc is None or not proc.alive: diff --git a/proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py b/proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py index 0871696..e872de3 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py @@ -73,6 +73,11 @@ def build_tree(client, bridge): async def _cleanup(interaction, force: bool = False): await bridge.handle_slash(interaction, "cleanup", force=force) + @tree.command(name="permisiuni", description="Ce s-a aprobat pentru tot firul") + @app_commands.describe(revoca="Sterge aprobarile firului; totul cere iar confirmare") + async def _permisiuni(interaction, revoca: bool = False): + await bridge.handle_slash(interaction, "permisiuni", revoca=revoca) + @tree.command(name="help", description="Lista comenzilor puntii") async def _help(interaction): await bridge.handle_slash(interaction, "help") diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md index cc7819b..7b43468 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md @@ -18,7 +18,7 @@ http://127.0.0.1:18790 <- local / prin tunel SSH | **Butoane** | Pornește / Oprește / Repornește puntea, cauta si curata procese orfane (`cleanup.py`), reporneste dashboard-ul insusi | | **Fire active** | ce e in `state.json`: fir, model, `cwd`, daca procesul `claude` traieste, daca are tur in desfasurare, cost | | **Diagnostic** | 8 verificari (vezi mai jos), reimprospatate la 30s | -| **Confirmari in asteptare** | cererile hook-ului `PreToolUse` — se pot aproba/refuza direct din pagina, nu doar din Discord | +| **Confirmari in asteptare** | cererile hook-ului `PreToolUse` — se pot aproba/refuza direct din pagina, nu doar din Discord. *Permite (tot firul)* memoreaza tiparul si nu mai intreaba in firul respectiv (vezi [`../security/README.md`](../security/README.md)) | | **Jurnal** | ultimele 300 de linii din `bot.log` sau `infra.log` | Starea se reimprospateaza automat la 5 secunde. @@ -93,7 +93,7 @@ cand `DASHBOARD_AUTH=off` (vezi mai jos). | GET | `/api/approvals` | cererile `pending` | | GET/POST | `/api/cleanup` | GET = doar cauta; POST `{"dry_run": false}` = omoara orfanii | | POST | `/api/service` | `{"action": "start\|stop\|restart", "force": bool}` | -| POST | `/api/approvals/decide` | `{"request_id": "...", "decision": "allow\|deny"}` | +| POST | `/api/approvals/decide` | `{"request_id": "...", "decision": "allow\|allow_session\|deny"}` | | POST | `/api/restart-self` | reporneste dashboard-ul | | POST | `/api/auth/login` / `/api/auth/logout` | `{"token": "..."}` / sterge cookie-ul (inutile cu `DASHBOARD_AUTH=off`) | diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py index 127a11a..05b9b26 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py @@ -596,7 +596,9 @@ class Handler(SimpleHTTPRequestHandler): data = self.read_json() rid = str(data.get("request_id") or "") decision = str(data.get("decision") or "") - if decision not in ("allow", "deny"): + # `allow_session` = permite si nu mai intreba in firul asta (vezi + # security/README.md, sectiunea "Aprobari valabile pe tot firul"). + if decision not in ("allow", "allow_session", "deny"): return self.send_json({"ok": False, "error": "decizie invalida"}, 400) if not rid or "/" in rid or ".." in rid: return self.send_json({"ok": False, "error": "request_id invalid"}, 400) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html index 11ceb23..ff00701 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html @@ -191,6 +191,7 @@ async function refreshApprovals() { ' · fir ' + esc(a.thread_id) + '' + '
' + '' + + '' + '' + '
'; }).join('') : '
niciuna
'; diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example index fdfa750..0d206cb 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example +++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example @@ -44,6 +44,13 @@ IDLE_REAP_S=1200 # CONSTRANGERE: trebuie sa ramana MAI MIC decat `timeout` din bot-settings.json # (acum 330), altfel CLI-ul taie hook-ul inainte ca acesta sa refuze curat. CLAUDE_DISCORD_APPROVAL_TIMEOUT=300 +# Butonul "Allow (tot firul)": aprobarea data o data tine pentru tot firul, ca sa +# nu se ceara confirmare la fiecare `ssh pvemini ...`. Se memoreaza tiparul +# (regula + motiv), nu comanda; `/permisiuni` le listeaza, `/permisiuni +# revoca:True` si `/new` le sterg. Pune `off` ca sa se confirme iar per comanda. +CLAUDE_DISCORD_SESSION_GRANTS=on +# Cat traieste o aprobare pe fir daca nimeni nu o revoca, secunde (implicit 12h). +CLAUDE_DISCORD_GRANT_TTL=43200 # --- Alerte (alerts.py) ---------------------------------------------------- # Destinatarul emailurilor de alerta. Aceeasi conventie ca restul repo-ului diff --git a/proxmox/lxc171-claude-agent/discord-bridge/security/README.md b/proxmox/lxc171-claude-agent/discord-bridge/security/README.md index 38c7d18..f9f65d5 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/security/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/security/README.md @@ -71,20 +71,87 @@ canalul dintre ele e un director pe disc si nu memoria botului. } ``` -- `status`: `pending` -> `allow` / `deny`. Botul schimba doar `status`, `decision`, `decided_at`. +- `status`: `pending` -> `allow` / `deny`. Botul schimba doar `status`, `decision`, `decided_at` + si `scope`. - `thread_id` vine din variabila de mediu `CLAUDE_DISCORD_THREAD_ID`, pe care Lane A o pune in mediul procesului `claude` al firului respectiv. Lipsa ei inseamna `null` si cererea ajunge in canalul principal. - Dupa decizie, hook-ul muta fisierul in `approvals/done/.json` (cu `finished_at`), ca `pending_requests()` sa nu-l mai vada. `cleanup_stale()` sterge ce e mai vechi de o zi. +## 1b. Aprobari valabile pe tot firul ("nu ma mai intreba") + +Confirmarea per comanda devine obositoare intr-o sesiune care lucreaza pe acelasi host: +`ssh pvemini ...` de zece ori la rand inseamna zece butoane. De aceea butonul de confirmare +are trei variante: + +| Buton | Ce face | +|---|---| +| **Allow** | permite comanda asta si atat | +| **Allow (tot firul)** | permite comanda si **memoreaza tiparul** pentru firul curent | +| **Deny** | refuza | + +Ce se memoreaza nu e comanda, ci perechea `(rule, reason)` produsa de clasificator: + +``` +host_productie|comanda catre hostul de productie 10.0.20.201 +serviciu_infra|systemctl stop pe serviciul de infra oracle-xe +rm_recursiv|stergere recursiva (rm -r) +``` + +Asa aprobarea e utila fara sa fie oarba: dupa un „Allow (tot firul)" pe `ssh pvemini uptime`, +orice comanda catre **acel** host trece singura, dar `ssh 10.0.20.36` sau un `rm -rf` cer din +nou confirmare. Aprobarile stau in + +``` +~/.claude-discord/approvals/grants/.json +``` + +```json +{ + "thread_id": "1234567890", + "created_at": 1756512000.0, + "updated_at": 1756512130.0, + "grants": { + "host_productie|comanda catre hostul de productie 10.0.20.201": { + "rule": "host_productie", + "reason": "comanda catre hostul de productie 10.0.20.201", + "granted_at": 1756512130.0, + "granted_by": null, + "session_id": "b1c2..." + } + } +} +``` + +**Domeniul e firul Discord, nu id-ul de sesiune Claude.** Un `--resume` poate schimba +`session_id`, iar aprobarile ar disparea exact cand omul se astepta sa tina. Firul e ce vede +utilizatorul si e stabil. + +Cand expira: + +- **`/new`** (sesiune noua in fir) le sterge — sesiune noua, permisiuni noi; +- **`/permisiuni revoca:True`** le sterge la cerere; `/permisiuni` le listeaza; +- automat dupa `CLAUDE_DISCORD_GRANT_TTL` secunde (implicit 12h — o zi de lucru, nu vesnicia); +- `CLAUDE_DISCORD_SESSION_GRANTS=off` dezactiveaza complet mecanismul (se revine la + confirmare per comanda). + +Si aici regula e **fail-closed**: fara `CLAUDE_DISCORD_THREAD_ID` (hook rulat in afara puntii), +cu fisierul de aprobari corupt, cu un `thread_id` care nu arata a id (`../`, punct la inceput, +peste 128 de caractere) sau la orice exceptie, `has_grant()` raspunde `False` si se cere +confirmare in Discord ca pana acum. + ### API-ul consumat de bot (contract INTERFACES.md) ```python await approvals.wait_for_decision(request_id, timeout) # "allow" | "deny" (timeout => deny) approvals.submit_decision(request_id, "allow") # True daca cererea exista +approvals.submit_decision(request_id, "allow_session") # allow + scope="thread" await approvals.pending_requests() # cereri in asteptare approvals.set_on_request(callback) # callback async la fiecare cerere noua + +approvals.list_grants(thread_id) # aprobarile valabile ale firului +approvals.clear_grants(thread_id) # cate a revocat ``` `set_on_request` porneste un watcher pe directorul de cereri (poll 0.5s) daca exista o bucla @@ -98,6 +165,7 @@ Orice abatere inseamna **deny**, cu motiv explicit trimis inapoi in CLI: - `~/.claude-discord` lipseste (hook-ul nu improvizeaza un director nou); - cererea nu poate fi scrisa pe disc; - fisierul cererii dispare sau devine JSON corupt in timpul asteptarii; +- fisierul de aprobari pe fir lipseste, e corupt, expirat sau fara `thread_id` valid; - niciun raspuns in `CLAUDE_DISCORD_APPROVAL_TIMEOUT` secunde (implicit 300); - orice alta exceptie, prinsa de plasa finala din `main()`. diff --git a/proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py b/proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py index 16f1463..e6b1c91 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py @@ -3,8 +3,9 @@ Hook-ul `confirm_hook.py` ruleaza in alt proces decat botul (il porneste CLI-ul `claude`), deci canalul dintre ele este un director de cereri: - ~/.claude-discord/approvals/.json cerere in asteptare + ~/.claude-discord/approvals/.json cerere in asteptare ~/.claude-discord/approvals/done/.json cerere incheiata + ~/.claude-discord/approvals/grants/.json aprobari valabile pe fir Regula de baza: FAIL-CLOSED. Orice eroare, timeout, fisier corupt sau director lipsa inseamna "deny". Modulul nu atinge reteaua si nu stie nimic despre Discord. @@ -32,6 +33,11 @@ except Exception: # pragma: no cover ALLOW = "allow" DENY = "deny" PENDING = "pending" +# Decizie de buton, nu stare pe disc: "permite si nu ma mai intreba in firul asta". +ALLOW_SESSION = "allow_session" + +# Cat traieste o aprobare de fir daca nimeni nu o revoca. Ziua de lucru, nu vesnicia. +GRANT_MAX_AGE_S = 12 * 3600.0 _POLL_S = 0.2 # cat de des verificam decizia pe disc _WATCH_S = 0.5 # cat de des verificam cereri noi pentru bot @@ -60,6 +66,11 @@ def done_dir() -> pathlib.Path: return approvals_dir() / "done" +def grants_dir() -> pathlib.Path: + """Aprobarile valabile pentru tot firul, cate un fisier per fir Discord.""" + return approvals_dir() / "grants" + + def log_dir() -> pathlib.Path: return state_dir() / "logs" @@ -76,6 +87,7 @@ def ensure_dirs() -> None: raise FileNotFoundError(f"directorul de stare lipseste: {base}") approvals_dir().mkdir(parents=True, exist_ok=True) done_dir().mkdir(parents=True, exist_ok=True) + grants_dir().mkdir(parents=True, exist_ok=True) # ------------------------------------------------------------ scriere atomica @@ -182,6 +194,17 @@ def read_decision(request_id: str) -> str: return DENY +def read_scope(request_id: str) -> str: + """`thread` daca s-a apasat "Allow (fir)", altfel `once`. Eroare => `once`.""" + try: + req = _read(request_path(request_id)) + except Exception: + return "once" + if not isinstance(req, dict): + return "once" + return "thread" if req.get("scope") == "thread" else "once" + + def finish_request(request_id: str, status: str, note: str = "") -> None: """Muta cererea in `done/`, ca `pending_requests()` sa nu o mai vada.""" try: @@ -217,6 +240,159 @@ def cleanup_stale(max_age_s: float = 86400.0) -> int: return n +# ------------------------------------------------- aprobari valabile pe fir +# Butonul "Allow (fir)" scrie aici. Hook-ul citeste inainte sa mai deranjeze +# omul: acelasi tipar de operatiune, in acelasi fir, nu se mai intreaba. +# +# ~/.claude-discord/approvals/grants/.json +# +# Cheia NU e comanda, ci perechea (regula, motiv) produsa de clasificator: +# "host_productie|comanda catre hostul de productie 10.0.20.201". Asta face +# aprobarea utila (orice comanda catre acel host trece) fara sa fie oarba +# (alt host, alt serviciu => cerere noua). + + +def grants_enabled() -> bool: + """`CLAUDE_DISCORD_SESSION_GRANTS=off` dezactiveaza complet mecanismul.""" + raw = (os.environ.get("CLAUDE_DISCORD_SESSION_GRANTS") or "").strip().lower() + return raw not in ("off", "0", "false", "no", "nu") + + +def grant_max_age_s() -> float: + raw = os.environ.get("CLAUDE_DISCORD_GRANT_TTL") + try: + if raw: + return max(0.0, float(raw)) + except (TypeError, ValueError): + pass + return GRANT_MAX_AGE_S + + +def _safe_scope(scope_id: str | None) -> str: + """Id de fir folosit ca nume de fisier. Orice suspiciune => ValueError.""" + sid = str(scope_id or "").strip() + if not sid or len(sid) > 128: + raise ValueError(f"scope invalid: {scope_id!r}") + if not all(c.isalnum() or c in "._-" for c in sid) or sid.startswith("."): + raise ValueError(f"scope invalid: {scope_id!r}") + return sid + + +def grant_path(scope_id: str) -> pathlib.Path: + return grants_dir() / f"{_safe_scope(scope_id)}.json" + + +def grant_key(rule: str, reason: str) -> str: + return f"{str(rule or '').strip()}|{str(reason or '').strip()}" + + +def _load_grants(scope_id: str) -> dict: + """Fisierul de aprobari al unui fir. Lipsa sau corupt => dict gol.""" + doc = _read(grant_path(scope_id)) + if not isinstance(doc, dict): + return {} + grants = doc.get("grants") + return grants if isinstance(grants, dict) else {} + + +def has_grant(scope_id: str | None, rule: str, reason: str) -> bool: + """Exista o aprobare de fir, inca valabila, pentru acest tipar? + + FAIL-CLOSED: fara scope, cu mecanismul oprit sau la orice eroare => False, + adica se cere confirmarea in Discord ca pana acum. + """ + if not grants_enabled(): + return False + try: + entry = _load_grants(_safe_scope(scope_id)).get(grant_key(rule, reason)) + if not isinstance(entry, dict): + return False + granted_at = float(entry.get("granted_at") or 0.0) + except Exception: + return False + ttl = grant_max_age_s() + return bool(granted_at) and (time.time() - granted_at) <= ttl + + +def add_grant( + scope_id: str | None, + rule: str, + reason: str, + *, + session_id: str | None = None, + granted_by: str | None = None, +) -> bool: + """Memoreaza tiparul pentru tot firul. False daca nu s-a putut scrie.""" + if not grants_enabled(): + return False + try: + sid = _safe_scope(scope_id) + ensure_dirs() + path = grant_path(sid) + doc = _read(path) + if not isinstance(doc, dict) or not isinstance(doc.get("grants"), dict): + doc = {"thread_id": sid, "created_at": time.time(), "grants": {}} + doc["thread_id"] = sid + doc["updated_at"] = time.time() + doc["grants"][grant_key(rule, reason)] = { + "rule": rule, + "reason": reason, + "granted_at": time.time(), + "granted_by": granted_by, + "session_id": session_id, + } + _write_atomic(path, doc) + return True + except Exception: + return False + + +def list_grants(scope_id: str | None) -> list[dict]: + """Aprobarile inca valabile ale unui fir, cele mai noi intai.""" + try: + grants = _load_grants(_safe_scope(scope_id)) + except Exception: + return [] + ttl = grant_max_age_s() + now = time.time() + out = [ + g for g in grants.values() + if isinstance(g, dict) and 0 <= (now - float(g.get("granted_at") or 0.0)) <= ttl + ] + out.sort(key=lambda g: float(g.get("granted_at") or 0.0), reverse=True) + return out + + +def clear_grants(scope_id: str | None) -> int: + """Revoca tot ce a fost aprobat pentru fir. Returneaza cate a sters.""" + try: + n = len(_load_grants(_safe_scope(scope_id))) + grant_path(str(scope_id)).unlink() + return n + except FileNotFoundError: + return 0 + except Exception: + return 0 + + +def cleanup_grants(max_age_s: float | None = None) -> int: + """Sterge fisierele de aprobari expirate. Returneaza cate a sters.""" + ttl = grant_max_age_s() if max_age_s is None else float(max_age_s) + n = 0 + now = time.time() + try: + for p in grants_dir().glob("*.json"): + try: + if now - p.stat().st_mtime > ttl: + p.unlink() + n += 1 + except OSError: + pass + except Exception: + pass + return n + + # ---------------------------------------------------- API publica (Lane A/bot) async def wait_for_decision(request_id: str, timeout: float) -> str: @@ -256,9 +432,12 @@ def submit_decision(request_id: str, decision: str) -> bool: req = _read(path) if req is None: return False - valid = decision in (ALLOW, DENY) - req["status"] = decision if valid else DENY - req["decision"] = req["status"] + valid = decision in (ALLOW, DENY, ALLOW_SESSION) + # `allow_session` este tot un `allow` pe disc; deosebirea sta in `scope`, + # pe care hook-ul o citeste ca sa memoreze tiparul pentru tot firul. + req["status"] = ALLOW if decision == ALLOW_SESSION else (decision if valid else DENY) + req["decision"] = decision if valid else DENY + req["scope"] = "thread" if decision == ALLOW_SESSION else "once" req["decided_at"] = time.time() try: _write_atomic(path, req) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/security/confirm_hook.py b/proxmox/lxc171-claude-agent/discord-bridge/security/confirm_hook.py index 60b195d..0e57730 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/security/confirm_hook.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/security/confirm_hook.py @@ -406,6 +406,17 @@ def run(payload_text: str) -> int: except Exception as exc: return _deny(f"hook de confirmare: modulul de aprobari lipseste ({exc})") + # Firul Discord e "sesiunea": aprobarea data o data acolo tine pentru tot + # firul. Orice eroare aici inseamna doar ca intrebam din nou (fail-closed). + scope_id = os.environ.get("CLAUDE_DISCORD_THREAD_ID") + try: + already = approvals.has_grant(scope_id, rule, reason) + except Exception: + already = False + if already: + _log(f"ALLOW (aprobat pe fir {scope_id}) rule={rule} cmd={command[:160]}") + return _allow(f"aprobat pentru firul curent ({reason})") + timeout = _timeout_s() try: req = approvals.create_request( @@ -434,7 +445,25 @@ def run(payload_text: str) -> int: _log(f"DENY {rid} exceptie la asteptare: {exc}") if decision == "allow": + scope = "once" + try: + scope = approvals.read_scope(rid) + except Exception: + scope = "once" approvals.finish_request(rid, "allow") + if scope == "thread": + saved = False + try: + saved = approvals.add_grant( + scope_id, rule, reason, session_id=payload.get("session_id") + ) + except Exception: + saved = False + _log(f"ALLOW {rid} rule={rule} scope=fir salvat={saved}") + if saved: + return _allow( + f"aprobat in Discord pentru tot firul (cerere {rid}, {reason})" + ) _log(f"ALLOW {rid} rule={rule}") return _allow(f"aprobat in Discord (cerere {rid}, {reason})") diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py index 8517aed..3f4ea52 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py @@ -299,6 +299,33 @@ def test_decizia_ajunge_la_lane_b(bridge, allowed, monkeypatch): assert "Permis" in out +def test_allow_pe_fir_spune_ca_nu_mai_intreaba(bridge, allowed, monkeypatch): + monkeypatch.setattr(bot.approvals, "submit_decision", lambda rid, dec: True) + out = bridge.decide(USER, "req-1", "allow_session") + assert "nu se mai" in out and "/permisiuni" in out + + +def test_permisiuni_listeaza_si_revoca(bridge, allowed): + assert "Nicio aprobare" in bridge.permissions_text("200") + bot.approvals.add_grant("200", "host_productie", "comanda catre hostul pvemini") + assert "host_productie" in bridge.permissions_text("200") + assert "Am revocat 1" in bridge.permissions_text("200", revoke=True) + assert "Nicio aprobare" in bridge.permissions_text("200") + + +def test_permisiuni_fara_lane_b_nu_arunca(bridge, monkeypatch): + monkeypatch.setattr(bot, "approvals", None) + assert "nu e disponibil" in bridge.permissions_text("200") + + +async def test_sesiune_noua_revoca_aprobarile_firului(bridge, allowed): + bot.approvals.add_grant("200", "rm_recursiv", "stergere recursiva (rm -r)") + ch = FakeChannel() + await bridge.handle_slash(FakeInteraction(channel=ch), "new") + assert "Sesiune noua" in ch.all_text and "revocat si 1" in ch.all_text + assert bot.approvals.list_grants("200") == [] + + def test_cerere_inexistenta_nu_arunca(bridge, allowed): assert "nu mai exista" in bridge.decide(USER, "req-inexistent", "deny") diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_confirm_hook.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_confirm_hook.py index 2dc218f..04574f9 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_confirm_hook.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_confirm_hook.py @@ -406,3 +406,141 @@ def test_hook_ca_proces_separat_comanda_banala_tace(home): ) assert res.returncode == 0 assert res.stdout.strip() == "" + + +# ------------------------------------------- aprobari valabile pe tot firul + +def payload_fir(cmd: str) -> str: + return payload(cmd) + + +def _decide_async(rid_holder, decision: str, home_dir: pathlib.Path): + """Fir de executie care apasa butonul din Discord dupa ce apare cererea.""" + + def apasa(): + for _ in range(80): + for p in (home_dir / "approvals").glob("*.json"): + rid_holder.append(p.stem) + approvals.submit_decision(p.stem, decision) + return + time.sleep(0.05) + + th = threading.Thread(target=apasa) + th.start() + return th + + +@pytest.fixture() +def fir(monkeypatch): + monkeypatch.setenv("CLAUDE_DISCORD_THREAD_ID", "1234567890") + return "1234567890" + + +def test_allow_session_memoreaza_tiparul_si_a_doua_oara_nu_mai_intreaba(home, fir, capsys): + """Prima comanda cere confirmare; a doua, de acelasi fel, trece singura.""" + rids: list[str] = [] + th = _decide_async(rids, approvals.ALLOW_SESSION, home) + hook.run(payload("pct destroy 171")) + th.join() + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["permissionDecision"] == "allow" + assert "tot firul" in out["hookSpecificOutput"]["permissionDecisionReason"] + + # a doua oara: niciun buton apasat, si totusi allow — instantaneu + t0 = time.monotonic() + hook.run(payload("pct destroy 999")) + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["permissionDecision"] == "allow" + assert "firul curent" in out["hookSpecificOutput"]["permissionDecisionReason"] + assert time.monotonic() - t0 < 0.5 + assert not list((home / "approvals").glob("*.json")) # nicio cerere noua + + +def test_allow_simplu_nu_memoreaza_nimic(home, fir, capsys): + rids: list[str] = [] + th = _decide_async(rids, approvals.ALLOW, home) + hook.run(payload("pct destroy 171")) + th.join() + assert json.loads(capsys.readouterr().out)["hookSpecificOutput"]["permissionDecision"] == "allow" + assert approvals.list_grants(fir) == [] + # a doua comanda cere din nou confirmare si, fara raspuns, e refuzata + hook.run(payload("pct destroy 999")) + assert json.loads(capsys.readouterr().out)["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_aprobarea_e_legata_de_tipar_nu_de_orice_comanda(home, fir, capsys): + """Aprobarea pentru un host de productie nu deschide si `rm -rf`.""" + rids: list[str] = [] + th = _decide_async(rids, approvals.ALLOW_SESSION, home) + hook.run(payload("ssh root@10.0.20.201 uptime")) + th.join() + capsys.readouterr() + + hook.run(payload("ssh root@10.0.20.201 pct list")) # acelasi host: trece + assert json.loads(capsys.readouterr().out)["hookSpecificOutput"]["permissionDecision"] == "allow" + + hook.run(payload("rm -rf /tmp/x")) # alta regula: cere din nou, deci deny la timeout + assert json.loads(capsys.readouterr().out)["hookSpecificOutput"]["permissionDecision"] == "deny" + + hook.run(payload("ssh root@10.0.20.36 uptime")) # alt host: cere din nou + assert json.loads(capsys.readouterr().out)["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_aprobarea_nu_trece_intre_fire(home, fir, monkeypatch, capsys): + rids: list[str] = [] + th = _decide_async(rids, approvals.ALLOW_SESSION, home) + hook.run(payload("pct destroy 171")) + th.join() + capsys.readouterr() + monkeypatch.setenv("CLAUDE_DISCORD_THREAD_ID", "9999999999") + hook.run(payload("pct destroy 171")) + assert json.loads(capsys.readouterr().out)["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_fara_thread_id_nu_exista_aprobari_de_fir(home, monkeypatch, capsys): + """Hook rulat in afara puntii: fail-closed, se intreaba de fiecare data.""" + monkeypatch.delenv("CLAUDE_DISCORD_THREAD_ID", raising=False) + rids: list[str] = [] + th = _decide_async(rids, approvals.ALLOW_SESSION, home) + hook.run(payload("pct destroy 171")) + th.join() + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["permissionDecision"] == "allow" + assert "tot firul" not in out["hookSpecificOutput"]["permissionDecisionReason"] + hook.run(payload("pct destroy 171")) + assert json.loads(capsys.readouterr().out)["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_mecanismul_poate_fi_oprit_din_env(home, fir, monkeypatch, capsys): + monkeypatch.setenv("CLAUDE_DISCORD_SESSION_GRANTS", "off") + assert approvals.add_grant(fir, "proxmox_destroy", "x") is False + assert approvals.has_grant(fir, "proxmox_destroy", "x") is False + + +def test_aprobarea_expira(home, fir, monkeypatch): + assert approvals.add_grant(fir, "proxmox_destroy", "pct destroy") is True + assert approvals.has_grant(fir, "proxmox_destroy", "pct destroy") is True + monkeypatch.setenv("CLAUDE_DISCORD_GRANT_TTL", "0") + assert approvals.has_grant(fir, "proxmox_destroy", "pct destroy") is False + assert approvals.list_grants(fir) == [] + + +def test_fisier_de_aprobari_corupt_nu_permite_nimic(home, fir): + approvals.add_grant(fir, "proxmox_destroy", "pct destroy") + approvals.grant_path(fir).write_text("}{ corupt", encoding="utf-8") + assert approvals.has_grant(fir, "proxmox_destroy", "pct destroy") is False + + +def test_scope_periculos_e_refuzat(home): + for rau in ("", "../../etc/passwd", ".ascuns", "a/b", "x" * 200): + assert approvals.has_grant(rau, "r", "m") is False + assert approvals.add_grant(rau, "r", "m") is False + + +def test_revocarea_sterge_tot(home, fir): + approvals.add_grant(fir, "proxmox_destroy", "unu") + approvals.add_grant(fir, "rm_recursiv", "doi") + assert len(approvals.list_grants(fir)) == 2 + assert approvals.clear_grants(fir) == 2 + assert approvals.list_grants(fir) == [] + assert approvals.clear_grants(fir) == 0