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

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