Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/security

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. Atentie la regulile deny din settings: ele au precedenta peste bypassPermissions si opresc turul inainte de hook. O verificare mai veche a concluzionat gresit ca sunt doar strat cosmetic (se testase /usr/bin/ssh -V si bash -c "ssh -V", care ocolesc potrivirea pe prefix); un ssh host cmd scris normal e insa refuzat sec. De aceea Bash(ssh:*) si Bash(scp:*) au fost scoase din bot-settings.json pe 2026-08-30 — cu ele acolo, puntea nu putea ajunge la niciun host (simptom: „permisiunea a fost respinsa" la ssh moltbot@10.0.20.173, desi cheia si reteaua erau in regula). Nu le pune la loc: accesul SSH la infrastructura e functionalitate ceruta, iar bariera reala e hook-ul de mai jos.

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 si scope.
  • 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.

1b. Aprobari valabile pe tot firul ("nu ma mai intreba")

Confirmarea per comanda devine obositoare intr-o sesiune care lucreaza pe acelasi host: ssh pvemini ... de zece ori la rand inseamna zece butoane. De aceea butonul de confirmare are trei variante:

Buton Ce face
Allow permite comanda asta si atat
Allow (tot firul) permite comanda si memoreaza tiparul pentru firul curent
Deny refuza

Ce se memoreaza nu e comanda, ci perechea (rule, reason) produsa de clasificator:

host_productie|comanda catre hostul de productie 10.0.20.201
serviciu_infra|systemctl stop pe serviciul de infra oracle-xe
rm_recursiv|stergere recursiva (rm -r)

Asa aprobarea e utila fara sa fie oarba: dupa un „Allow (tot firul)" 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/<thread_id>.json
{
  "thread_id": "1234567890",
  "created_at": 1756512000.0,
  "updated_at": 1756512130.0,
  "grants": {
    "host_productie|comanda catre hostul de productie 10.0.20.201": {
      "rule": "host_productie",
      "reason": "comanda catre hostul de productie 10.0.20.201",
      "granted_at": 1756512130.0,
      "granted_by": null,
      "session_id": "b1c2..."
    }
  }
}

Domeniul e firul Discord, nu id-ul de sesiune Claude. Un --resume poate schimba session_id, iar aprobarile ar disparea exact cand omul se astepta sa tina. Firul e ce vede utilizatorul si e stabil.

Cand expira:

  • /new (sesiune noua in fir) le sterge — sesiune noua, permisiuni noi;
  • /permisiuni revoca:True le sterge la cerere; /permisiuni le listeaza;
  • automat dupa CLAUDE_DISCORD_GRANT_TTL secunde (implicit 12h — o zi de lucru, nu vesnicia);
  • CLAUDE_DISCORD_SESSION_GRANTS=off dezactiveaza complet mecanismul (se revine la confirmare per comanda).

Si aici regula e fail-closed: 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 ca pana acum.

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
approvals.submit_decision(request_id, "allow_session")  # allow + scope="thread"
await approvals.pending_requests()                      # cereri in asteptare
approvals.set_on_request(callback)                      # callback async la fiecare cerere noua

approvals.list_grants(thread_id)                        # aprobarile valabile ale firului
approvals.clear_grants(thread_id)                       # cate a revocat

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;
  • fisierul de aprobari pe fir lipseste, e corupt, expirat sau fara thread_id valid;
  • 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.