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:
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:<sonnet|opus>` 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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()`.
|
||||
|
||||
|
||||
@@ -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/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)
|
||||
|
||||
@@ -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})")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user