Files
Claude Agent 1cc1a572d9 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
2026-08-31 07:36:34 +00:00

6.2 KiB

INTERFACES — contract intre lane-uri (proprietate: orchestrator, NU modifica)

Cele trei lane-uri lucreaza in acelasi director. Acest fisier fixeaza cine ce fisier scrie si ce semnaturi trec granita, ca merge-ul sa fie mecanic.

Proprietate pe fisiere (STRICTA — nu scrie in fisierele altui lane)

Lane Fisiere pe care le creeaza/editeaza
A (nucleu + adaptor) session_store.py, stream.py, runner.py, render.py, limits.py, bot.py, config.py, tests/**, requirements.txt, requirements-dev.txt
B (securitate) security/confirm_hook.py, security/infra, security/approvals.py, security/bot-settings.json.example, security/README.md, tests/test_confirm_hook.py, tests/test_infra.py
C (ops) alerts.py, cleanup.py, dashboard/**, ops/claude-discord.service, ops/install.sh, ops/logrotate.conf, tests/test_alerts.py, tests/test_cleanup.py, tests/test_dashboard.py, README.md, si liniile de index din ../README.md + /workspace/romfastsql/CLAUDE.md

Fisiere partajate ca citire: acest INTERFACES.md. Nimeni nu-l editeaza.

Layout runtime (in afara repo)

~/.claude-discord/
  env                     # 0600: DISCORD_TOKEN, allowlist, ALERT_RECIPIENT, COST_CAP_USD_DAY
  bot-settings.json       # settings pasat cu --settings (hook PreToolUse) — Lane B
  state.json              # stare sesiuni — Lane A
  venv/
  logs/bot.log

Modelul de date state.json (Lane A e autoritatea)

{
  "version": 1,
  "threads": {
    "<discord_thread_or_channel_id>": {
      "sid": "uuid sesiune claude sau null",
      "cwd": "/workspace/<proiect>",
      "model": "sonnet",
      "pid": 12345,
      "pid_start_time": 987654.21,
      "inflight": {"turn_id": "...", "started_at": 1756512000.0, "user_id": "...", "message_id": "..."},
      "cost_usd_total": 0.0,
      "last_active": 1756512000.0
    }
  },
  "cost": {"day": "2026-08-30", "usd": 0.0}
}

pid_start_time = /proc/<pid>/stat campul 22 (starttime), convertit in SECUNDE de la boot (ticks / os.sysconf('SC_CLK_TCK')), pentru detectarea PID reuse. Unitatea conteaza: un consumator care compara direct ticks-urile din /proc cu valoarea din state.json nu se va potrivi NICIODATA, iar daca acea comparatie protejeaza ceva (cleanup.py), esecul e tacut si periculos — tot ce trebuia protejat devine eligibil pentru omorare.

Granita A <-> B (aprobari)

Lane B expune security/approvals.py. Lane A il importa si nu-i cunoaste interiorul.

# security/approvals.py — implementat de Lane B, consumat de Lane A
async def wait_for_decision(request_id: str, timeout: float) -> str: ...
    # returneaza "allow" | "deny"; la timeout returneaza "deny" (fail-closed)

def submit_decision(request_id: str, decision: str) -> bool: ...
    # apelat de bot.py cand utilizatorul apasa butonul; True daca cererea exista

async def pending_requests() -> list[dict]: ...
    # [{"request_id", "thread_id", "tool_name", "command", "created_at"}]

def set_on_request(callback) -> None: ...
    # Lane A inregistreaza aici un async callback(request: dict) apelat cand
    # hook-ul cere o confirmare; bot.py posteaza atunci butoanele in fir.

Canalul hook -> bot e un director de cereri pe disc (~/.claude-discord/approvals/), fiindca hook-ul PreToolUse ruleaza intr-un proces separat, nu in botul Python. Lane B alege formatul; Lane A vede doar functiile de mai sus.

Fail-closed e obligatoriu: orice eroare, timeout sau fisier corupt => deny.

Granita A <-> C (alerte)

Lane C expune alerts.py. Lane A il apeleaza in caile de esec.

# alerts.py — implementat de Lane C, consumat de Lane A
def alert(level: str, subject: str, body: str, dedup_key: str | None = None) -> None: ...
    # level: "INFO" | "WARN" | "CRITICAL"
    # trimite email prin `mail -s "[LEVEL] subject" "$ALERT_RECIPIENT"` (conventia repo,
    # vezi proxmox/vm109-windows-dr/scripts/pveelite-down-alert.sh)
    # NU arunca niciodata exceptii — o alerta esuata nu are voie sa doboare botul
    # dedup_key: aceeasi cheie nu retrimite in fereastra de 1h

Conditiile pe care Lane A le semnaleaza (T12): proces mort neasteptat, crash loop, plafon de cost atins, state.json corupt, orfani detectati la sweep.

Granita A <-> C (cleanup)

# cleanup.py — implementat de Lane C, consumat de bot.py pentru comanda !cleanup
def find_orphans(state: dict) -> list[dict]: ...
    # procese `claude` din cgroup-ul serviciului care nu apar in state.json,
    # plus copii lasati in urma (servere pornite in tururi anterioare)
    # -> [{"pid", "cmdline", "age_s", "rss_mb"}]
def kill_orphans(orphans: list[dict], dry_run: bool = True) -> list[dict]: ...

Granita comuna: config

Lane A creeaza config.py, care citeste ~/.claude-discord/env. B si C il importa pentru cai si setari; nu-si citesc singure env-ul.

# config.py — implementat de Lane A
STATE_DIR: pathlib.Path      # ~/.claude-discord
APPROVALS_DIR: pathlib.Path  # ~/.claude-discord/approvals
STATE_FILE: pathlib.Path
LOG_DIR: pathlib.Path
def get(key: str, default=None) -> str | None: ...   # citeste din env-ul incarcat

Daca config.py nu exista inca la momentul in care B sau C au nevoie de el (lane-uri paralele), scrie codul care il importa oricum — se rezolva la merge — si NU crea o varianta proprie.

Reguli de test

  • pytest fara marker: zero retea, zero Discord, zero API. Suita sub 3s.
  • Testele care ating CLI-ul real: @pytest.mark.e2e, excluse implicit prin pytest.ini (Lane A scrie pytest.ini cu addopts = -m "not e2e").
  • Fiecare lane isi scrie doar propriile fisiere de test, dupa tabelul de proprietate.

Decizii deja luate (nu le redeschide)

  • --permission-mode bypassPermissions e intentionat; deny rules sunt strat cosmetic, nu bariera.
  • Accesul larg la /workspace si la infrastructura e FUNCTIONALITATE ceruta, nu bug.
  • 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).