# 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/.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/.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/.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/ 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= ``` 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.