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
141 lines
6.2 KiB
Markdown
141 lines
6.2 KiB
Markdown
# 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)
|
|
|
|
```json
|
|
{
|
|
"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.
|
|
|
|
```python
|
|
# 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.
|
|
|
|
```python
|
|
# 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)
|
|
|
|
```python
|
|
# 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.
|
|
|
|
```python
|
|
# 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`).
|