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

@@ -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/<request_id>.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/<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)
```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()`.

View File

@@ -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/<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/grants/<thread_id>.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/<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)
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)

View File

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