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:
282
proxmox/lxc171-claude-agent/discord-bridge/security/README.md
Normal file
282
proxmox/lxc171-claude-agent/discord-bridge/security/README.md
Normal 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.
|
||||
Reference in New Issue
Block a user