feat(discord-bridge): aprobari valabile pe tot firul (buton "Allow (tot firul)")

Confirmarea per comanda devenea obositoare intr-o sesiune care lucreaza pe
acelasi host: `ssh pvemini ...` de zece ori la rand insemna zece butoane.

Butonul de confirmare are acum trei variante: Allow / Allow (tot firul) / Deny.
"Allow (tot firul)" memoreaza tiparul `(rule, reason)` produs de clasificator,
nu comanda: dupa o aprobare 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/<fir>.json.

Domeniul e firul Discord, nu `session_id`: acela se schimba la `--resume`, iar
aprobarile ar disparea exact cand omul se astepta sa tina.

Expirare: `/new` le sterge (sesiune noua = permisiuni noi), `/permisiuni
revoca:True` la cerere, TTL implicit 12h (CLAUDE_DISCORD_GRANT_TTL), iar
CLAUDE_DISCORD_SESSION_GRANTS=off dezactiveaza complet mecanismul.

Fail-closed peste tot, ca restul hook-ului: 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.

Adaugat si `/permisiuni [revoca:True]` (listare/revocare) plus butonul
echivalent in dashboard (`decision: "allow_session"`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
This commit is contained in:
Claude Agent
2026-08-31 07:36:34 +00:00
parent feb31eaabe
commit 1cc1a572d9
13 changed files with 526 additions and 14 deletions

View File

@@ -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`) |

View File

@@ -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)

View File

@@ -191,6 +191,7 @@ async function refreshApprovals() {
' · fir ' + esc(a.thread_id) + '</div></div>' +
'<div class="check-actions">' +
'<button class="small primary" onclick="decide(\'' + esc(a.request_id) + '\',\'allow\')">Permite</button>' +
'<button class="small" title="Permite și nu mai întreba pentru operațiuni de același fel în acest fir" onclick="decide(\'' + esc(a.request_id) + '\',\'allow_session\')">Permite (tot firul)</button>' +
'<button class="small danger" onclick="decide(\'' + esc(a.request_id) + '\',\'deny\')">Refuză</button>' +
'</div></div>';
}).join('') : '<div class="empty">niciuna</div>';