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