feat(discord-bridge): punte Discord -> Claude Code pe LXC 171

Implementeaza planul claude-master-plan-discord-bridge-20260830 (15 taskuri,
3 lane-uri paralele) — un bot subtire discord.py peste CLI-ul `claude`, cu
proces persistent per fir alimentat pe stdin cu --input-format stream-json.

Nucleu: runner (proces persistent + reaper 20min + respawn --resume), stream
(parser tolerant), session_store (scriere atomica, lock per fir, detectare PID
reuse, recovery), limits (max 4 procese, timeout tur, rate per user, plafon cost
pe zi), render (un loop de editare per canal, interval adaptiv).

Adaptor: allowlist guild/canal/user fail-closed cu respingerea webhook-urilor,
comenzi !new/!cd/!model/!status/!stop/!cleanup, cost si model in subsolul
fiecarui raspuns. Mesajul sosit in timpul unui tur devine steering, nu tur nou.

Securitate: hook PreToolUse fail-closed care cere confirmare in Discord pentru
operatiuni ireversibile, wrapper `infra` cu lista explicita de hosturi. Deny
rules raman strat cosmetic, nu bariera (verificat: /usr/bin/ssh trece pe langa).

Ops: alerte email pe conventia repo-ului, !cleanup pentru orfani, unit systemd
user cu KillMode=control-group si limite de memorie, install.sh idempotent.

Verificat: 275 teste fara retea/Discord/API (10.8s), identic cu si fara
discord.py instalat; e2e pe CLI real confirma steering-ul mid-tur (mesaj la 6s
intr-un tool call de 25s schimba raspunsul final).

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-30 10:44:39 +00:00
parent d7b4007af8
commit d466f358ce
43 changed files with 7767 additions and 0 deletions

View File

@@ -0,0 +1,282 @@
# Securitatea puntii Discord -> Claude Code (Lane B)
Botul ruleaza CLI-ul `claude` cu `--permission-mode bypassPermissions`. Asta e o decizie
deliberata: accesul la nodurile Proxmox, la LXC-uri si la VM-uri este **functionalitate ceruta**,
nu accident. S-a verificat empiric ca regulile `deny` din settings **nu sunt o bariera**
(`/usr/bin/ssh -V` si `bash -c "ssh -V"` trec pe langa ele) — raman doar strat cosmetic.
Straturile reale sunt:
| Strat | Unde | Ce face |
|---|---|---|
| 1. Control de acces | `bot.py` (Lane A) | cine poate scrie in canal |
| 2. Confirmare pentru operatiuni ireversibile | `confirm_hook.py` + `approvals.py` | hook PreToolUse care blocheaza si asteapta un buton in Discord |
| 4. Poarta spre infrastructura | `infra` + token Proxmox cu ACL | hosturi dintr-o lista explicita, fiecare apel jurnalizat |
Stratul 3 (audit append-only pe branch dedicat) a fost respins constient de utilizator.
---
## 1. Fluxul de confirmare
```
claude (bypassPermissions)
| PreToolUse (JSON pe stdin)
v
confirm_hook.py --- clasificator ---> nepericuloasa ---> exit 0, fara iesire (flux normal)
|
| ireversibila
v
~/.claude-discord/approvals/<request_id>.json (status: pending)
| ^
| (bot.py vede cererea prin | submit_decision("allow"|"deny")
| set_on_request si posteaza |
| butoanele in fir) |
v |
polling pe disc, pana la 300s -----------+
|
v
{"hookSpecificOutput": {"permissionDecision": "allow"|"deny", ...}}
```
Hook-ul si botul sunt **procese diferite** (hook-ul e pornit de CLI-ul `claude`), de aceea
canalul dintre ele e un director pe disc si nu memoria botului.
### Formatul fisierului de cerere
`~/.claude-discord/approvals/<request_id>.json`, scris atomic (tmp + `os.replace`):
```json
{
"request_id": "3f9a1c2b7d4e5f60",
"thread_id": "1234567890",
"session_id": "b1c2...",
"tool_name": "Bash",
"command": "rm -rf /var/lib/vz/dump",
"rule": "rm_recursiv",
"reason": "stergere recursiva (rm -r)",
"cwd": "/workspace/romfastsql",
"created_at": 1756512000.0,
"expires_at": 1756512300.0,
"status": "pending",
"decision": null,
"decided_at": null,
"decided_by": null
}
```
- `status`: `pending` -> `allow` / `deny`. Botul schimba doar `status`, `decision`, `decided_at`.
- `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.
### 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
await approvals.pending_requests() # cereri in asteptare
approvals.set_on_request(callback) # callback async la fiecare cerere noua
```
`set_on_request` porneste un watcher pe directorul de cereri (poll 0.5s) daca exista o bucla
asyncio activa; `set_on_request(None)` il opreste. Un callback care arunca nu opreste watcher-ul.
### Fail-closed
Orice abatere inseamna **deny**, cu motiv explicit trimis inapoi in CLI:
- JSON invalid sau payload care nu e obiect;
- `~/.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;
- niciun raspuns in `CLAUDE_DISCORD_APPROVAL_TIMEOUT` secunde (implicit 300);
- orice alta exceptie, prinsa de plasa finala din `main()`.
Toate cazurile de mai sus au test in `tests/test_confirm_hook.py`.
---
## 2. Ce prinde clasificatorul
Analizeaza doar tool-ul `Bash`. Comanda e tokenizata cu `shlex` (operatorii `;`, `&&`, `||`, `|`
raman token-uri separate), impartita in segmente, iar fiecare segment e curatat de wrappere
(`sudo`, `env FOO=1`, `timeout 30`, `nohup`, `nice`, atribuiri `VAR=val`) inainte de a fi
clasificat pe numele de baza al executabilului (deci `/bin/rm` = `rm`). Intra recursiv in
`bash -c "..."`, `sh -c "..."`, `ssh host "..."`, `pct exec ... -- ...`, `docker exec ... ...`
(maxim 5 niveluri).
Reguli: `rm -r`, `rm -f` pe cai de sistem, `find -delete`, `shred`, `dd`, `mkfs*`, `wipefs`,
`fdisk`/`parted`/`sgdisk`, redirectare in `/dev/...` (mai putin `/dev/null|stdout|stderr|tty`),
`shutdown`/`reboot`/`halt`/`poweroff`/`init 0|6`, `pct|qm destroy|restore`, `pvesh delete`,
`pvesm remove|free`, `pveceph destroy*|purge`, `zfs destroy|rollback`, `zpool destroy|labelclear`,
`lvremove`/`vgremove`/`pvremove`, `systemctl stop|disable|mask|kill` pe servicii de infra,
`systemctl -H`, `git push --force`, `git clean -f`, `git reset --hard`, `docker system prune`,
`docker volume rm`, `docker rm -f`, `chmod|chown -R` pe cai de sistem, `DROP`/`TRUNCATE` pe
obiecte Oracle, si orice `ssh`/`scp`/`rsync`/`infra` catre un host de productie
(10.0.20.36, .37, .200, .201, .202, `pve1`, `pvemini`, `pveelite`, `roacentral`).
## 3. Ce NU prinde (limitele asumate)
Acesta e un strat impotriva **accidentelor**, nu impotriva unui atacator. Cine controleaza
promptul poate ocoli detectia banal. Concret, NU sunt prinse:
- **Ofuscarea**: `echo cm0gLXJmIC8= | base64 -d | sh`, `R=rm; $R -rf /tmp/x`,
`python3 -c "import shutil; shutil.rmtree('/x')"`, `perl -e 'unlink...'`, `eval "$CMD"`.
Hook-ul vede text, nu semantica.
- **Comenzi dintr-un fisier**: `./cleanup.sh`, `make clean`, `npm run reset`, un hook git —
continutul scriptului nu e citit. La fel `sqlplus @drop_all.sql`: numele fisierului nu spune
ce e inauntru.
- **Alte tool-uri**: `Write`, `Edit`, `NotebookEdit`, tool-urile MCP nu trec prin clasificator.
Un `.sh` distructiv poate fi scris fara confirmare (rularea lui, insa, se vede ca `Bash`).
- **Variabile si expansiuni**: `rm -rf "$DIR"` cere confirmare (regula `rm -r`), dar
`rm -f "$FILES"` nu, fiindca nu stim ce contine variabila.
- **API in loc de CLI**: `curl -X DELETE https://10.0.20.201:8006/api2/json/nodes/...` nu e
clasificat ca stergere (doar hostul de productie ar fi prins daca executabilul ar fi ssh/rsync;
cu `curl` nu e).
- **`ssh` cu optiuni cu valoare**: `ssh -p 2222 host cmd` poate incurca detectarea hostului
(valoarea `2222` e luata drept host), deci comanda remota nu mai e analizata.
- **Falsuri pozitive acceptate**: orice `rm -r`, chiar in `/tmp`, cere confirmare. E zgomot
deliberat: un clic e mai ieftin decat un director sters gresit.
- **Auto-aprobarea**: hook-ul si botul ruleaza sub acelasi utilizator, deci un agent care vrea
neaparat isi poate scrie singur `"status": "allow"` in fisierul cererii. Consecinta directa a
respingerii stratului 3 (user separat / audit independent), asumata explicit.
---
## 4. Wrapper `infra`
```bash
infra --list # hosturile permise
infra pvemini pct list # ruleaza comanda pe nodul Proxmox
infra oracle docker ps
INFRA_DRY_RUN=1 infra pvemini uptime # arata comanda ssh, nu o executa
```
- Hostul e cautat intr-o lista **explicita**. Un host absent e refuzat imediat, fara DNS:
`exit 3`. Fara comanda: `exit 2`. Fisier de hosturi corupt: `exit 4`. Altfel, codul de iesire
este cel al comenzii remote.
- Lista implicita e in `infra` (`DEFAULT_HOSTS`) si poate fi inlocuita integral cu
`~/.claude-discord/infra-hosts.json`:
```json
{
"pvemini": {"addr": "10.0.20.201", "user": "root", "prod": true, "desc": "nod principal"},
"oracle": {"addr": "10.0.20.121", "user": "root"},
"oracle-prod": {"addr": "10.0.20.36", "user": "romfast", "prod": true}
}
```
Daca fisierul exista, **inlocuieste** lista implicita (nu se adauga la ea).
- Fiecare apel — inclusiv refuzurile — se scrie pe o linie in `~/.claude-discord/logs/infra.log`:
```
2026-08-30T11:20:41 host=pvemini target=root@10.0.20.201 rc=0 dur=0.42s cmd=pct list
2026-08-30T11:21:03 host=router.local target=- rc=refuzat dur=0.00s cmd=reboot note=host in afara listei
```
Jurnalul e un ajutor de depanare, nu un audit: ruleaza sub acelasi user si poate fi rescris.
---
## 5. Instalare
```bash
mkdir -p ~/.claude-discord/{approvals/done,logs}
chmod 700 ~/.claude-discord
# settings pasat botului cu --settings
cp proxmox/lxc171-claude-agent/discord-bridge/security/bot-settings.json.example \
~/.claude-discord/bot-settings.json
# ajusteaza calea absoluta a hook-ului daca repo-ul nu e in /workspace/romfastsql
# wrapper-ul in PATH
ln -s /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/security/infra ~/bin/infra
```
Variabile de mediu (puse de Lane A in mediul procesului `claude`):
| Variabila | Rol | Implicit |
|---|---|---|
| `CLAUDE_DISCORD_DIR` | muta `~/.claude-discord` (teste) | `~/.claude-discord` |
| `CLAUDE_DISCORD_APPROVAL_TIMEOUT` | cat asteapta hook-ul o decizie, in secunde | `300` |
| `CLAUDE_DISCORD_THREAD_ID` | firul in care se posteaza butoanele | — |
| `INFRA_DRY_RUN` | `infra` doar tipareste comanda ssh | — |
Atentie: `timeout` din `bot-settings.json` (330s) trebuie sa ramana **mai mare** decat
`CLAUDE_DISCORD_APPROVAL_TIMEOUT`, altfel CLI-ul taie hook-ul inainte sa apuce sa refuze curat.
---
## 6. Token Proxmox cu ACL restrans (pasi manuali)
**Nu a fost creat nimic pe cluster.** Comenzile de mai jos se ruleaza de om, ca `root` pe
`pvemini` (10.0.20.201). Tokenul acopera operatiile de *citire si control de alimentare* pe care
le vrea puntea; `VM.Allocate` (crearea/distrugerea de guest-uri) este **intentionat lasat afara**.
```bash
# 1. utilizator dedicat pentru punte
pveum user add claude-bridge@pve --comment "punte Discord -> Claude Code (LXC 171)"
# 2. rol cu strictul necesar
# - audit/monitorizare: sa poata raspunde la "ce mai face clusterul"
# - PowerMgmt + Console: start/stop/reboot pe guest si `pct exec`-uri prin API
pveum role add ClaudeBridge -privs "\
Datastore.Audit,\
Sys.Audit,Sys.Console,Sys.Syslog,\
VM.Audit,VM.Monitor,VM.Console,VM.PowerMgmt"
# 3. legarea rolului de utilizator (pe tot arborele; restrange la /vms/<id> daca vrei mai putin)
pveum acl modify / --users claude-bridge@pve --roles ClaudeBridge
# 4. tokenul propriu-zis, cu separare de privilegii activa
pveum user token add claude-bridge@pve discord --privsep 1
# ^ afiseaza SECRETUL O SINGURA DATA. Copiaza-l acum.
# 5. ACL explicit pentru token (necesar cand privsep=1)
pveum acl modify / --tokens 'claude-bridge@pve!discord' --roles ClaudeBridge
# 6. verificare
pveum acl list
pveum user token list claude-bridge@pve
```
Pe LXC 171, secretul se pune in `~/.claude-discord/env` (fisier `0600`, deja folosit de Lane A):
```
PVE_API_URL=https://10.0.20.201:8006/api2/json
PVE_TOKEN_ID=claude-bridge@pve!discord
PVE_TOKEN_SECRET=<secretul afisat la pasul 4>
```
Test rapid (citeste, nu schimba nimic):
```bash
curl -sk -H "Authorization: PVEAPIToken=${PVE_TOKEN_ID}=${PVE_TOKEN_SECRET}" \
"${PVE_API_URL}/nodes" | jq '.data[].node'
```
Pentru revocare: `pveum user token remove claude-bridge@pve discord`.
**Ce ramane in sarcina omului:** pasii 1-6 de mai sus pe `pvemini`, copierea secretului in
`~/.claude-discord/env`, `chmod 600` pe acel fisier si decizia daca ACL-ul ramane pe `/` sau se
restrange la un subset de guest-uri. Puntea nu creeaza si nu roteste tokenul singura.
Tokenul **nu inlocuieste** cheile SSH existente din `~/.ssh` — retragerea lor a fost respinsa
deliberat, fiindca accesul SSH la infrastructura e functionalitate ceruta. Tokenul e o cale
alternativa, cu drepturi mai mici, pentru operatiile care se pot face prin API.
---
## 7. Teste
```bash
cd proxmox/lxc171-claude-agent/discord-bridge
python3 -m pytest tests/test_confirm_hook.py tests/test_infra.py -q
```
Fara retea, fara Discord, fara cluster. `tests/test_infra.py` ruleaza totul cu `INFRA_DRY_RUN=1`,
iar `tests/test_confirm_hook.py` include si un test in care hook-ul e pornit ca proces separat si
aprobat din exterior — exact granita reala dintre hook si bot.