Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/security/README.md
Claude Agent d466f358ce 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
2026-08-30 10:44:39 +00:00

12 KiB

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):

{
  "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)

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

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:

    {
      "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

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.

# 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):

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

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.