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

@@ -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 user separat `cdbot`, fara audit append-only, fara dashboard web, fara Agent SDK.
- Fara reluare automata a turului pierdut (risc de dubla executie). - Fara reluare automata a turului pierdut (risc de dubla executie).
- Model default `sonnet`; `!model opus` per fir. - 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`).

View File

@@ -61,6 +61,7 @@ Cateva alegeri care nu se vad din diagrama:
| `/status` | Sesiune, director, model, cost cumulat, proces viu, ultimele linii de stderr | | `/status` | Sesiune, director, model, cost cumulat, proces viu, ultimele linii de stderr |
| `/stop` | Opreste turul in desfasurare din firul curent | | `/stop` | Opreste turul in desfasurare din firul curent |
| `/cleanup` | Listeaza procesele lasate in urma (rulare seaca). `/cleanup force:True` le opreste | | `/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 | | `/help` | Lista de mai sus, in fir |
Comenzile sunt **application commands** (`/`), inregistrate pe guild-urile din 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 2. **Confirmare pentru operatiuni ireversibile.** Un hook `PreToolUse` opreste comanda si
posteaza butoane in fir; fara raspuns in fereastra de timp raspunsul e **deny** 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 (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. 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 Ce **nu** apara: regulile `deny` din settings. Sub `bypassPermissions` ele sunt un strat

View File

@@ -206,10 +206,14 @@ class Command:
return " ".join(self.args) 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. # 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]: def slash_args(name: str, opts: dict) -> list[str]:
@@ -403,7 +407,7 @@ class Bridge:
await channel.send(text) await channel.send(text)
def approval_view(self, request_id: str): 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: if discord is None or approvals is None:
return None return None
view = discord.ui.View(timeout=None) view = discord.ui.View(timeout=None)
@@ -420,6 +424,7 @@ class Bridge:
return button return button
view.add_item(_mk("Allow", "allow", discord.ButtonStyle.success)) 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)) view.add_item(_mk("Deny", "deny", discord.ButtonStyle.danger))
return view return view
@@ -435,6 +440,12 @@ class Bridge:
ok = bool(approvals.submit_decision(request_id, decision)) ok = bool(approvals.submit_decision(request_id, decision))
if not ok: if not ok:
return "⚠️ Cererea nu mai exista (expirata sau deja decisa)." 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}>." return f"{'✅ Permis' if decision == 'allow' else '🚫 Refuzat'} de <@{user_id}>."
# ------------------------------------------------------------- intrare # ------------------------------------------------------------- intrare
@@ -537,6 +548,7 @@ class Bridge:
"`/model model:<sonnet|opus>` schimba modelul firului\n" "`/model model:<sonnet|opus>` schimba modelul firului\n"
"`/status` starea firului · `/stop` opreste turul curent\n" "`/status` starea firului · `/stop` opreste turul curent\n"
"`/cleanup [force:True]` procese lasate in urma\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.", "Orice alt mesaj din canal pleaca la Claude ca prompt.",
) )
@@ -548,10 +560,16 @@ class Bridge:
if not fork: if not fork:
fields["sid"] = None fields["sid"] = None
self.store.update_thread(tid, **fields) 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( await self.say(
message.channel, message.channel,
"🧵 Sesiune noua pornita din contextul celei curente (`--fork`)." ("🧵 Sesiune noua pornita din contextul celei curente (`--fork`)."
if fork else "🧵 Sesiune noua, curata.", 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: 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```") lines.append("stderr (ultimele linii):\n```\n" + body + "\n```")
return "\n".join(lines) 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: async def cmd_stop(self, message, tid: str, cmd: Command) -> None:
proc = self.runner.procs.get(tid) proc = self.runner.procs.get(tid)
if proc is None or not proc.alive: if proc is None or not proc.alive:

View File

@@ -73,6 +73,11 @@ def build_tree(client, bridge):
async def _cleanup(interaction, force: bool = False): async def _cleanup(interaction, force: bool = False):
await bridge.handle_slash(interaction, "cleanup", force=force) 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") @tree.command(name="help", description="Lista comenzilor puntii")
async def _help(interaction): async def _help(interaction):
await bridge.handle_slash(interaction, "help") await bridge.handle_slash(interaction, "help")

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 | | **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 | | **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 | | **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` | | **Jurnal** | ultimele 300 de linii din `bot.log` sau `infra.log` |
Starea se reimprospateaza automat la 5 secunde. Starea se reimprospateaza automat la 5 secunde.
@@ -93,7 +93,7 @@ cand `DASHBOARD_AUTH=off` (vezi mai jos).
| GET | `/api/approvals` | cererile `pending` | | GET | `/api/approvals` | cererile `pending` |
| GET/POST | `/api/cleanup` | GET = doar cauta; POST `{"dry_run": false}` = omoara orfanii | | GET/POST | `/api/cleanup` | GET = doar cauta; POST `{"dry_run": false}` = omoara orfanii |
| POST | `/api/service` | `{"action": "start\|stop\|restart", "force": bool}` | | 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/restart-self` | reporneste dashboard-ul |
| POST | `/api/auth/login` / `/api/auth/logout` | `{"token": "..."}` / sterge cookie-ul (inutile cu `DASHBOARD_AUTH=off`) | | 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() data = self.read_json()
rid = str(data.get("request_id") or "") rid = str(data.get("request_id") or "")
decision = str(data.get("decision") 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) return self.send_json({"ok": False, "error": "decizie invalida"}, 400)
if not rid or "/" in rid or ".." in rid: if not rid or "/" in rid or ".." in rid:
return self.send_json({"ok": False, "error": "request_id invalid"}, 400) 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>' + ' · fir ' + esc(a.thread_id) + '</div></div>' +
'<div class="check-actions">' + '<div class="check-actions">' +
'<button class="small primary" onclick="decide(\'' + esc(a.request_id) + '\',\'allow\')">Permite</button>' + '<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>' + '<button class="small danger" onclick="decide(\'' + esc(a.request_id) + '\',\'deny\')">Refuză</button>' +
'</div></div>'; '</div></div>';
}).join('') : '<div class="empty">niciuna</div>'; }).join('') : '<div class="empty">niciuna</div>';

View File

@@ -44,6 +44,13 @@ IDLE_REAP_S=1200
# CONSTRANGERE: trebuie sa ramana MAI MIC decat `timeout` din bot-settings.json # 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. # (acum 330), altfel CLI-ul taie hook-ul inainte ca acesta sa refuze curat.
CLAUDE_DISCORD_APPROVAL_TIMEOUT=300 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) ---------------------------------------------------- # --- Alerte (alerts.py) ----------------------------------------------------
# Destinatarul emailurilor de alerta. Aceeasi conventie ca restul repo-ului # Destinatarul emailurilor de alerta. Aceeasi conventie ca restul repo-ului

View File

@@ -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 - `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 mediul procesului `claude` al firului respectiv. Lipsa ei inseamna `null` si cererea ajunge
in canalul principal. in canalul principal.
- Dupa decizie, hook-ul muta fisierul in `approvals/done/<request_id>.json` (cu `finished_at`), - Dupa decizie, hook-ul muta fisierul in `approvals/done/<request_id>.json` (cu `finished_at`),
ca `pending_requests()` sa nu-l mai vada. `cleanup_stale()` sterge ce e mai vechi de o zi. 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/<thread_id>.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) ### API-ul consumat de bot (contract INTERFACES.md)
```python ```python
await approvals.wait_for_decision(request_id, timeout) # "allow" | "deny" (timeout => deny) 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") # True daca cererea exista
approvals.submit_decision(request_id, "allow_session") # allow + scope="thread"
await approvals.pending_requests() # cereri in asteptare await approvals.pending_requests() # cereri in asteptare
approvals.set_on_request(callback) # callback async la fiecare cerere noua 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 `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); - `~/.claude-discord` lipseste (hook-ul nu improvizeaza un director nou);
- cererea nu poate fi scrisa pe disc; - cererea nu poate fi scrisa pe disc;
- fisierul cererii dispare sau devine JSON corupt in timpul asteptarii; - 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); - niciun raspuns in `CLAUDE_DISCORD_APPROVAL_TIMEOUT` secunde (implicit 300);
- orice alta exceptie, prinsa de plasa finala din `main()`. - orice alta exceptie, prinsa de plasa finala din `main()`.

View File

@@ -5,6 +5,7 @@ Hook-ul `confirm_hook.py` ruleaza in alt proces decat botul (il porneste CLI-ul
~/.claude-discord/approvals/<request_id>.json cerere in asteptare ~/.claude-discord/approvals/<request_id>.json cerere in asteptare
~/.claude-discord/approvals/done/<request_id>.json cerere incheiata ~/.claude-discord/approvals/done/<request_id>.json cerere incheiata
~/.claude-discord/approvals/grants/<thread_id>.json aprobari valabile pe fir
Regula de baza: FAIL-CLOSED. Orice eroare, timeout, fisier corupt sau director 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. lipsa inseamna "deny". Modulul nu atinge reteaua si nu stie nimic despre Discord.
@@ -32,6 +33,11 @@ except Exception: # pragma: no cover
ALLOW = "allow" ALLOW = "allow"
DENY = "deny" DENY = "deny"
PENDING = "pending" 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 _POLL_S = 0.2 # cat de des verificam decizia pe disc
_WATCH_S = 0.5 # cat de des verificam cereri noi pentru bot _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" 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: def log_dir() -> pathlib.Path:
return state_dir() / "logs" return state_dir() / "logs"
@@ -76,6 +87,7 @@ def ensure_dirs() -> None:
raise FileNotFoundError(f"directorul de stare lipseste: {base}") raise FileNotFoundError(f"directorul de stare lipseste: {base}")
approvals_dir().mkdir(parents=True, exist_ok=True) approvals_dir().mkdir(parents=True, exist_ok=True)
done_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 # ------------------------------------------------------------ scriere atomica
@@ -182,6 +194,17 @@ def read_decision(request_id: str) -> str:
return DENY 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: def finish_request(request_id: str, status: str, note: str = "") -> None:
"""Muta cererea in `done/`, ca `pending_requests()` sa nu o mai vada.""" """Muta cererea in `done/`, ca `pending_requests()` sa nu o mai vada."""
try: try:
@@ -217,6 +240,159 @@ def cleanup_stale(max_age_s: float = 86400.0) -> int:
return n 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/<thread_id>.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) # ---------------------------------------------------- API publica (Lane A/bot)
async def wait_for_decision(request_id: str, timeout: float) -> str: 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) req = _read(path)
if req is None: if req is None:
return False return False
valid = decision in (ALLOW, DENY) valid = decision in (ALLOW, DENY, ALLOW_SESSION)
req["status"] = decision if valid else DENY # `allow_session` este tot un `allow` pe disc; deosebirea sta in `scope`,
req["decision"] = req["status"] # 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() req["decided_at"] = time.time()
try: try:
_write_atomic(path, req) _write_atomic(path, req)

View File

@@ -406,6 +406,17 @@ def run(payload_text: str) -> int:
except Exception as exc: except Exception as exc:
return _deny(f"hook de confirmare: modulul de aprobari lipseste ({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() timeout = _timeout_s()
try: try:
req = approvals.create_request( req = approvals.create_request(
@@ -434,7 +445,25 @@ def run(payload_text: str) -> int:
_log(f"DENY {rid} exceptie la asteptare: {exc}") _log(f"DENY {rid} exceptie la asteptare: {exc}")
if decision == "allow": if decision == "allow":
scope = "once"
try:
scope = approvals.read_scope(rid)
except Exception:
scope = "once"
approvals.finish_request(rid, "allow") 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}") _log(f"ALLOW {rid} rule={rule}")
return _allow(f"aprobat in Discord (cerere {rid}, {reason})") return _allow(f"aprobat in Discord (cerere {rid}, {reason})")

View File

@@ -299,6 +299,33 @@ def test_decizia_ajunge_la_lane_b(bridge, allowed, monkeypatch):
assert "Permis" in out 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): def test_cerere_inexistenta_nu_arunca(bridge, allowed):
assert "nu mai exista" in bridge.decide(USER, "req-inexistent", "deny") assert "nu mai exista" in bridge.decide(USER, "req-inexistent", "deny")

View File

@@ -406,3 +406,141 @@ def test_hook_ca_proces_separat_comanda_banala_tace(home):
) )
assert res.returncode == 0 assert res.returncode == 0
assert res.stdout.strip() == "" 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