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.
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Stratul de securitate al puntii Discord -> Claude Code (Lane B).
|
||||
|
||||
Contine canalul de aprobari pe disc (approvals.py), hook-ul PreToolUse
|
||||
(confirm_hook.py) si wrapper-ul `infra` pentru accesul la infrastructura.
|
||||
"""
|
||||
350
proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py
Normal file
350
proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Canal de aprobari pe disc intre hook-ul PreToolUse si botul Discord.
|
||||
|
||||
Hook-ul `confirm_hook.py` ruleaza in alt proces decat botul (il porneste CLI-ul
|
||||
`claude`), deci canalul dintre ele este un director de cereri:
|
||||
|
||||
~/.claude-discord/approvals/<request_id>.json cerere in asteptare
|
||||
~/.claude-discord/approvals/done/<request_id>.json cerere incheiata
|
||||
|
||||
Regula de baza: FAIL-CLOSED. Orice eroare, timeout, fisier corupt sau director
|
||||
lipsa inseamna "deny". Modulul nu atinge reteaua si nu stie nimic despre Discord.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import time
|
||||
import uuid
|
||||
|
||||
# config.py apartine Lane A si poate lipsi in unele contexte (hook rulat izolat).
|
||||
# Import tolerant, cu acelasi fallback pe ~/.claude-discord.
|
||||
try: # pragma: no cover - depinde de contextul de import
|
||||
from .. import config as _config # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
try:
|
||||
import config as _config # type: ignore
|
||||
except Exception:
|
||||
_config = None # type: ignore
|
||||
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
PENDING = "pending"
|
||||
|
||||
_POLL_S = 0.2 # cat de des verificam decizia pe disc
|
||||
_WATCH_S = 0.5 # cat de des verificam cereri noi pentru bot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- cai pe disc
|
||||
|
||||
def state_dir() -> pathlib.Path:
|
||||
"""~/.claude-discord, cu CLAUDE_DISCORD_DIR ca override (folosit in teste)."""
|
||||
override = os.environ.get("CLAUDE_DISCORD_DIR")
|
||||
if override:
|
||||
return pathlib.Path(override)
|
||||
if _config is not None:
|
||||
try:
|
||||
return pathlib.Path(_config.STATE_DIR)
|
||||
except Exception:
|
||||
pass
|
||||
return pathlib.Path.home() / ".claude-discord"
|
||||
|
||||
|
||||
def approvals_dir() -> pathlib.Path:
|
||||
return state_dir() / "approvals"
|
||||
|
||||
|
||||
def done_dir() -> pathlib.Path:
|
||||
return approvals_dir() / "done"
|
||||
|
||||
|
||||
def log_dir() -> pathlib.Path:
|
||||
return state_dir() / "logs"
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
"""Creeaza subdirectoarele de aprobari.
|
||||
|
||||
Nu creeaza directorul de baza: daca ~/.claude-discord lipseste inseamna ca
|
||||
puntea nu e instalata, iar hook-ul trebuie sa refuze (fail-closed), nu sa
|
||||
improvizeze un director nou.
|
||||
"""
|
||||
base = state_dir()
|
||||
if not base.is_dir():
|
||||
raise FileNotFoundError(f"directorul de stare lipseste: {base}")
|
||||
approvals_dir().mkdir(parents=True, exist_ok=True)
|
||||
done_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ scriere atomica
|
||||
|
||||
def _write_atomic(path: pathlib.Path, payload: dict) -> None:
|
||||
"""tmp + os.replace, ca un cititor sa nu vada niciodata JSON pe jumatate."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.parent / f".{path.name}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp"
|
||||
data = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
fh.write(data)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
try:
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _read(path: pathlib.Path) -> dict | None:
|
||||
"""Citeste o cerere. JSON corupt sau fisier disparut => None."""
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------- API interna
|
||||
# (folosita de confirm_hook.py; botul nu are nevoie de ea)
|
||||
|
||||
def new_request_id() -> str:
|
||||
return uuid.uuid4().hex[:16]
|
||||
|
||||
|
||||
def create_request(
|
||||
*,
|
||||
tool_name: str,
|
||||
command: str,
|
||||
reason: str = "",
|
||||
rule: str = "",
|
||||
thread_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
cwd: str | None = None,
|
||||
timeout: float = 300.0,
|
||||
request_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Scrie o cerere de confirmare si o returneaza. Arunca daca nu poate scrie."""
|
||||
ensure_dirs()
|
||||
rid = request_id or new_request_id()
|
||||
now = time.time()
|
||||
req = {
|
||||
"request_id": rid,
|
||||
"thread_id": thread_id,
|
||||
"session_id": session_id,
|
||||
"tool_name": tool_name,
|
||||
"command": command,
|
||||
"rule": rule,
|
||||
"reason": reason,
|
||||
"cwd": cwd,
|
||||
"created_at": now,
|
||||
"expires_at": now + float(timeout),
|
||||
"status": PENDING,
|
||||
"decision": None,
|
||||
"decided_at": None,
|
||||
"decided_by": None,
|
||||
}
|
||||
_write_atomic(approvals_dir() / f"{rid}.json", req)
|
||||
return req
|
||||
|
||||
|
||||
def request_path(request_id: str) -> pathlib.Path:
|
||||
return approvals_dir() / f"{_safe_id(request_id)}.json"
|
||||
|
||||
|
||||
def _safe_id(request_id: str) -> str:
|
||||
"""Nu lasam un id sa evadeze din director prin `../`."""
|
||||
rid = str(request_id)
|
||||
if not rid or "/" in rid or "\\" in rid or rid.startswith("."):
|
||||
raise ValueError(f"request_id invalid: {rid!r}")
|
||||
return rid
|
||||
|
||||
|
||||
def read_decision(request_id: str) -> str:
|
||||
"""`allow` / `deny` / `pending`. Orice problema => `deny` (fail-closed)."""
|
||||
try:
|
||||
req = _read(request_path(request_id))
|
||||
except Exception:
|
||||
return DENY
|
||||
if req is None:
|
||||
return DENY
|
||||
status = req.get("status")
|
||||
if status == ALLOW:
|
||||
return ALLOW
|
||||
if status == PENDING:
|
||||
return PENDING
|
||||
return DENY
|
||||
|
||||
|
||||
def finish_request(request_id: str, status: str, note: str = "") -> None:
|
||||
"""Muta cererea in `done/`, ca `pending_requests()` sa nu o mai vada."""
|
||||
try:
|
||||
src = request_path(request_id)
|
||||
req = _read(src) or {"request_id": request_id}
|
||||
req["status"] = status if status in (ALLOW, DENY) else DENY
|
||||
req["finished_at"] = time.time()
|
||||
if note:
|
||||
req["note"] = note
|
||||
_write_atomic(done_dir() / f"{_safe_id(request_id)}.json", req)
|
||||
try:
|
||||
src.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cleanup_stale(max_age_s: float = 86400.0) -> int:
|
||||
"""Sterge cereri incheiate mai vechi de o zi. Returneaza cate a sters."""
|
||||
n = 0
|
||||
now = time.time()
|
||||
try:
|
||||
for p in done_dir().glob("*.json"):
|
||||
try:
|
||||
if now - p.stat().st_mtime > max_age_s:
|
||||
p.unlink()
|
||||
n += 1
|
||||
except OSError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return n
|
||||
|
||||
|
||||
# ---------------------------------------------------- API publica (Lane A/bot)
|
||||
|
||||
async def wait_for_decision(request_id: str, timeout: float) -> str:
|
||||
"""Asteapta decizia pentru o cerere. La timeout sau eroare returneaza `deny`."""
|
||||
deadline = time.monotonic() + max(0.0, float(timeout or 0))
|
||||
while True:
|
||||
decision = read_decision(request_id)
|
||||
if decision in (ALLOW, DENY):
|
||||
return decision
|
||||
if time.monotonic() >= deadline:
|
||||
return DENY
|
||||
await asyncio.sleep(_POLL_S)
|
||||
|
||||
|
||||
def wait_for_decision_sync(request_id: str, timeout: float) -> str:
|
||||
"""Varianta blocanta, pentru hook (proces separat, fara bucla asyncio)."""
|
||||
deadline = time.monotonic() + max(0.0, float(timeout or 0))
|
||||
while True:
|
||||
decision = read_decision(request_id)
|
||||
if decision in (ALLOW, DENY):
|
||||
return decision
|
||||
if time.monotonic() >= deadline:
|
||||
return DENY
|
||||
time.sleep(_POLL_S)
|
||||
|
||||
|
||||
def submit_decision(request_id: str, decision: str) -> bool:
|
||||
"""Apelata de bot.py cand utilizatorul apasa butonul.
|
||||
|
||||
True daca cererea exista si a fost marcata. O decizie nerecunoscuta este
|
||||
tratata ca `deny` si returneaza False.
|
||||
"""
|
||||
try:
|
||||
path = request_path(request_id)
|
||||
except Exception:
|
||||
return False
|
||||
req = _read(path)
|
||||
if req is None:
|
||||
return False
|
||||
valid = decision in (ALLOW, DENY)
|
||||
req["status"] = decision if valid else DENY
|
||||
req["decision"] = req["status"]
|
||||
req["decided_at"] = time.time()
|
||||
try:
|
||||
_write_atomic(path, req)
|
||||
except Exception:
|
||||
return False
|
||||
return valid
|
||||
|
||||
|
||||
async def pending_requests() -> list[dict]:
|
||||
"""Cererile inca in asteptare, cele mai vechi intai. Nu arunca niciodata."""
|
||||
out: list[dict] = []
|
||||
try:
|
||||
paths = sorted(approvals_dir().glob("*.json"))
|
||||
except Exception:
|
||||
return out
|
||||
for p in paths:
|
||||
req = _read(p)
|
||||
if not req or req.get("status") != PENDING:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"request_id": req.get("request_id") or p.stem,
|
||||
"thread_id": req.get("thread_id"),
|
||||
"tool_name": req.get("tool_name") or "",
|
||||
"command": req.get("command") or "",
|
||||
"created_at": req.get("created_at") or 0.0,
|
||||
"reason": req.get("reason") or "",
|
||||
}
|
||||
)
|
||||
out.sort(key=lambda r: r["created_at"])
|
||||
return out
|
||||
|
||||
|
||||
_on_request = None
|
||||
_watch_task = None
|
||||
_seen: set[str] = set()
|
||||
|
||||
|
||||
def set_on_request(callback) -> None:
|
||||
"""Inregistreaza un callback async apelat cand apare o cerere noua.
|
||||
|
||||
Botul posteaza atunci butoanele in firul Discord. Un callback `None`
|
||||
opreste urmarirea.
|
||||
"""
|
||||
global _on_request, _watch_task
|
||||
_on_request = callback
|
||||
if callback is None:
|
||||
stop_watcher()
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return # fara bucla activa nu pornim nimic; se reapeleaza din bot
|
||||
if _watch_task is None or _watch_task.done():
|
||||
_watch_task = loop.create_task(_watch_loop())
|
||||
|
||||
|
||||
def stop_watcher() -> None:
|
||||
global _watch_task
|
||||
if _watch_task is not None and not _watch_task.done():
|
||||
_watch_task.cancel()
|
||||
_watch_task = None
|
||||
|
||||
|
||||
async def _watch_loop() -> None:
|
||||
"""Urmareste directorul de cereri si anunta botul o singura data per cerere."""
|
||||
while True:
|
||||
try:
|
||||
for req in await pending_requests():
|
||||
rid = req["request_id"]
|
||||
if rid in _seen:
|
||||
continue
|
||||
_seen.add(rid)
|
||||
cb = _on_request
|
||||
if cb is None:
|
||||
continue
|
||||
try:
|
||||
res = cb(req)
|
||||
if asyncio.iscoroutine(res):
|
||||
await res
|
||||
except Exception:
|
||||
pass # un callback care crapa nu are voie sa opreasca botul
|
||||
if len(_seen) > 5000:
|
||||
_seen.clear()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(_WATCH_S)
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"_comentariu": "Sablon pentru ~/.claude-discord/bot-settings.json, pasat cu --settings. Copiaza-l si ajusteaza calea absoluta a hook-ului daca repo-ul nu e in /workspace/romfastsql.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/security/confirm_hook.py",
|
||||
"timeout": 330
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"_comentariu": "STRAT COSMETIC, NU BARIERA: sub --permission-mode bypassPermissions regulile deny nu opresc /usr/bin/ssh sau bash -c \"ssh\". Bariera reala e hook-ul PreToolUse de mai sus.",
|
||||
"deny": [
|
||||
"Bash(ssh:*)",
|
||||
"Bash(scp:*)",
|
||||
"Bash(pct destroy:*)",
|
||||
"Bash(qm destroy:*)",
|
||||
"Bash(zfs destroy:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hook PreToolUse: cere confirmare in Discord pentru operatiuni ireversibile.
|
||||
|
||||
Contract Claude Code: primeste pe stdin un JSON de forma
|
||||
|
||||
{"session_id": "...", "cwd": "...", "hook_event_name": "PreToolUse",
|
||||
"tool_name": "Bash", "tool_input": {"command": "..."}}
|
||||
|
||||
si raspunde pe stdout cu
|
||||
|
||||
{"hookSpecificOutput": {"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "allow"|"deny",
|
||||
"permissionDecisionReason": "..."}}
|
||||
|
||||
Comenzile nepericuloase nu produc nicio iesire (exit 0) si urmeaza fluxul normal.
|
||||
Cele periculoase produc o cerere in ~/.claude-discord/approvals/ si asteapta
|
||||
decizia botului.
|
||||
|
||||
FAIL-CLOSED: orice exceptie, timeout, director lipsa sau JSON corupt => deny.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
# Ca `import config` (Lane A) sa functioneze si cand hook-ul e pornit ca script.
|
||||
_HERE = pathlib.Path(__file__).resolve().parent
|
||||
for _p in (str(_HERE), str(_HERE.parent)):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
DEFAULT_TIMEOUT_S = 300.0
|
||||
|
||||
# ------------------------------------------------------------------ constante
|
||||
|
||||
# Prefixe care doar impacheteaza alta comanda; le desfacem inainte de analiza.
|
||||
_WRAPPERS = {
|
||||
"sudo", "doas", "nohup", "nice", "ionice", "time", "command", "exec",
|
||||
"stdbuf", "setsid", "env", "eatmydata",
|
||||
}
|
||||
# Wrappere care au un argument numeric/optiune proprie de sarit.
|
||||
_WRAPPER_OPT_ARG = {"timeout": 1, "nice": 0, "ionice": 0}
|
||||
|
||||
_SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "ash", "busybox"}
|
||||
|
||||
# Servicii de infrastructura: oprirea lor rupe ceva ce altcineva foloseste.
|
||||
_INFRA_SERVICES = (
|
||||
"pve", "pvedaemon", "pveproxy", "pvestatd", "pve-cluster", "pve-firewall",
|
||||
"corosync", "ceph", "zfs", "oracle", "oracle-xe", "flowise", "gitea",
|
||||
"docker", "containerd", "nginx", "apache2", "ttyd", "ssh", "sshd",
|
||||
"postgresql", "mysql", "mariadb", "tailscaled", "smbd", "nfs-server",
|
||||
"claude-discord", "nut-server", "nut-monitor",
|
||||
)
|
||||
|
||||
# Hosturi de productie: orice comanda remote catre ele cere confirmare.
|
||||
_PROD_HOSTS = (
|
||||
"10.0.20.36", "10.0.20.37", "10.0.20.200", "10.0.20.201", "10.0.20.202",
|
||||
"pve1", "pvemini", "pveelite", "oracle-prod", "dr", "roacentral",
|
||||
)
|
||||
_REMOTE_EXEC = {"ssh", "scp", "rsync", "sftp", "infra", "ansible", "ansible-playbook"}
|
||||
|
||||
_SQL_DESTRUCTIVE = re.compile(
|
||||
r"\b(drop|truncate)\s+"
|
||||
r"(table|user|tablespace|schema|database|index|view|sequence|materialized|"
|
||||
r"package|body|procedure|function|trigger|type|synonym|directory)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_SENSITIVE_ROOTS = ("/", "/etc", "/usr", "/boot", "/var", "/bin", "/sbin", "/lib", "/opt")
|
||||
|
||||
|
||||
# ------------------------------------------------------------- tokenizare
|
||||
|
||||
def _tokenize(cmd: str) -> list[str]:
|
||||
"""Imparte comanda in token-uri, cu `;`, `&&`, `||`, `|`, `>` separate.
|
||||
|
||||
Daca lexerul esueaza (ghilimele neinchise), cadem pe o impartire naiva --
|
||||
scopul e detectia, nu executia.
|
||||
"""
|
||||
try:
|
||||
lex = shlex.shlex(cmd, posix=True, punctuation_chars=True)
|
||||
lex.whitespace_split = True
|
||||
return list(lex)
|
||||
except Exception:
|
||||
return cmd.replace(";", " ; ").replace("|", " | ").split()
|
||||
|
||||
|
||||
_SEPARATORS = {";", "&&", "||", "|", "&", "\n"}
|
||||
|
||||
|
||||
def _segments(tokens: list[str]) -> list[list[str]]:
|
||||
"""Grupeaza token-urile in comenzi separate de operatori de shell."""
|
||||
out: list[list[str]] = []
|
||||
cur: list[str] = []
|
||||
for t in tokens:
|
||||
if t in _SEPARATORS:
|
||||
if cur:
|
||||
out.append(cur)
|
||||
cur = []
|
||||
else:
|
||||
cur.append(t)
|
||||
if cur:
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def _strip_wrappers(seg: list[str]) -> list[str]:
|
||||
"""Scoate `sudo`, `env FOO=1`, `timeout 30`, atribuiri VAR=val etc."""
|
||||
i = 0
|
||||
n = len(seg)
|
||||
while i < n:
|
||||
tok = seg[i]
|
||||
base = os.path.basename(tok)
|
||||
if "=" in tok and not tok.startswith("-") and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tok):
|
||||
i += 1
|
||||
continue
|
||||
if base in _WRAPPERS or base in _WRAPPER_OPT_ARG:
|
||||
i += 1
|
||||
# sarim optiunile wrapper-ului si eventualul argument (ex. timeout 30)
|
||||
while i < n and seg[i].startswith("-"):
|
||||
if base == "sudo" and seg[i] in ("-u", "-g", "-U"):
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
if base in _WRAPPER_OPT_ARG and _WRAPPER_OPT_ARG[base] and i < n:
|
||||
if re.match(r"^[0-9]+(\.[0-9]+)?[smhd]?$", seg[i]):
|
||||
i += 1
|
||||
continue
|
||||
break
|
||||
return seg[i:]
|
||||
|
||||
|
||||
def _has_flag(args: list[str], short: str, *longs: str) -> bool:
|
||||
for a in args:
|
||||
if a in longs:
|
||||
return True
|
||||
if a.startswith("--"):
|
||||
continue
|
||||
if short and a.startswith("-") and len(a) > 1 and short in a[1:]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _mentions_prod(tokens: list[str]) -> str | None:
|
||||
for t in tokens:
|
||||
low = t.lower()
|
||||
for host in _PROD_HOSTS:
|
||||
if low == host or low.endswith("@" + host) or low.startswith(host + ":"):
|
||||
return host
|
||||
if host[0].isdigit() and host in low:
|
||||
return host
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- clasificator
|
||||
|
||||
def classify_command(cmd: str, depth: int = 0) -> tuple[str, str] | None:
|
||||
"""Returneaza `(regula, motiv)` daca cere confirmare, altfel None."""
|
||||
if not cmd or not cmd.strip():
|
||||
return None
|
||||
if depth > 5:
|
||||
return ("prea_adanc", "comanda impachetata pe prea multe niveluri")
|
||||
|
||||
# SQL distructiv: cautam in textul brut, indiferent de shell.
|
||||
m = _SQL_DESTRUCTIVE.search(cmd)
|
||||
if m:
|
||||
return ("sql_destructiv", f"SQL ireversibil: {m.group(0).upper()}")
|
||||
|
||||
tokens = _tokenize(cmd)
|
||||
|
||||
# Redirectare catre /dev/... (suprascrie un disc sau un dispozitiv).
|
||||
for i, t in enumerate(tokens):
|
||||
if t in (">", ">>") and i + 1 < len(tokens) and tokens[i + 1].startswith("/dev/"):
|
||||
if not tokens[i + 1].startswith(("/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty")):
|
||||
return ("redirect_dev", f"scriere directa in {tokens[i + 1]}")
|
||||
|
||||
for seg in _segments(tokens):
|
||||
seg = _strip_wrappers(seg)
|
||||
if not seg:
|
||||
continue
|
||||
verdict = _classify_segment(seg, depth)
|
||||
if verdict:
|
||||
return verdict
|
||||
return None
|
||||
|
||||
|
||||
def _classify_segment(seg: list[str], depth: int) -> tuple[str, str] | None:
|
||||
exe = os.path.basename(seg[0])
|
||||
args = seg[1:]
|
||||
sub = args[0] if args else ""
|
||||
|
||||
# --- shell-uri si executii la distanta: intram in comanda dinauntru
|
||||
if exe in _SHELLS and "-c" in args:
|
||||
idx = args.index("-c")
|
||||
if idx + 1 < len(args):
|
||||
inner = classify_command(args[idx + 1], depth + 1)
|
||||
if inner:
|
||||
return inner
|
||||
if exe in ("ssh", "infra"):
|
||||
host = _mentions_prod(seg)
|
||||
if host:
|
||||
return ("host_productie", f"comanda catre hostul de productie {host}")
|
||||
# restul argumentelor formeaza comanda remote
|
||||
rest = [a for a in args if not a.startswith("-")][1:]
|
||||
if rest:
|
||||
inner = classify_command(" ".join(rest), depth + 1)
|
||||
if inner:
|
||||
return inner
|
||||
if exe in _REMOTE_EXEC:
|
||||
host = _mentions_prod(seg)
|
||||
if host:
|
||||
return ("host_productie", f"comanda catre hostul de productie {host}")
|
||||
if exe == "pct" and sub == "exec":
|
||||
rest = args[2:]
|
||||
if rest and rest[0] == "--":
|
||||
rest = rest[1:]
|
||||
if rest:
|
||||
inner = classify_command(" ".join(rest), depth + 1)
|
||||
if inner:
|
||||
return inner
|
||||
if exe == "docker" and sub == "exec":
|
||||
rest = [a for a in args[1:] if not a.startswith("-")][1:]
|
||||
if rest:
|
||||
inner = classify_command(" ".join(rest), depth + 1)
|
||||
if inner:
|
||||
return inner
|
||||
|
||||
# --- stergeri
|
||||
if exe == "rm":
|
||||
if _has_flag(args, "r", "--recursive") or _has_flag(args, "R"):
|
||||
return ("rm_recursiv", "stergere recursiva (rm -r)")
|
||||
if _has_flag(args, "f", "--force"):
|
||||
for a in args:
|
||||
if not a.startswith("-") and (a in ("/",) or a.rstrip("/") in _SENSITIVE_ROOTS):
|
||||
return ("rm_sistem", f"stergere in cale de sistem: {a}")
|
||||
if exe == "find" and ("-delete" in args or "-exec" in args and "rm" in args):
|
||||
return ("find_delete", "find cu stergere (-delete / -exec rm)")
|
||||
if exe == "shred":
|
||||
return ("shred", "suprascriere ireversibila (shred)")
|
||||
|
||||
# --- discuri si filesysteme
|
||||
if exe == "dd":
|
||||
return ("dd", "scriere directa pe bloc (dd)")
|
||||
if exe.startswith("mkfs") or exe in ("wipefs", "sgdisk", "sfdisk", "fdisk", "parted", "cfdisk", "mkswap"):
|
||||
return ("disc", f"operatie pe partitii/filesystem ({exe})")
|
||||
|
||||
# --- oprire/repornire
|
||||
if exe in ("shutdown", "reboot", "halt", "poweroff"):
|
||||
return ("oprire", f"oprirea sau repornirea masinii ({exe})")
|
||||
if exe == "init" and sub in ("0", "6"):
|
||||
return ("oprire", f"schimbare runlevel ({sub})")
|
||||
|
||||
# --- Proxmox / ZFS / LVM
|
||||
if exe in ("pct", "qm") and sub in ("destroy", "restore"):
|
||||
return ("proxmox_destroy", f"{exe} {sub} distruge/suprascrie un guest")
|
||||
if exe == "pvesm" and sub in ("remove", "free"):
|
||||
return ("proxmox_storage", f"pvesm {sub} pe un storage")
|
||||
if exe == "pvesh" and sub == "delete":
|
||||
return ("pvesh_delete", "apel API Proxmox de stergere (pvesh delete)")
|
||||
if exe == "pveceph" and sub in ("destroypool", "purge", "destroymon", "destroyosd"):
|
||||
return ("proxmox_ceph", f"pveceph {sub}")
|
||||
if exe == "zfs" and sub in ("destroy", "rollback"):
|
||||
return ("zfs_destroy", f"zfs {sub} este ireversibil")
|
||||
if exe == "zpool" and sub in ("destroy", "labelclear"):
|
||||
return ("zfs_destroy", f"zpool {sub} este ireversibil")
|
||||
if exe in ("lvremove", "vgremove", "pvremove"):
|
||||
return ("lvm", f"stergere LVM ({exe})")
|
||||
|
||||
# --- servicii
|
||||
if exe == "systemctl":
|
||||
if any(a in ("-H", "--host") for a in args):
|
||||
return ("systemctl_remote", "systemctl catre alt host")
|
||||
verb = ""
|
||||
targets: list[str] = []
|
||||
for a in args:
|
||||
if a.startswith("-"):
|
||||
continue
|
||||
if not verb:
|
||||
verb = a
|
||||
else:
|
||||
targets.append(a)
|
||||
if verb in ("poweroff", "reboot", "halt", "kexec", "emergency", "rescue"):
|
||||
return ("oprire", f"systemctl {verb}")
|
||||
if verb in ("stop", "disable", "mask", "kill"):
|
||||
for t in targets:
|
||||
name = t.split(".")[0].lower()
|
||||
if any(name == s or name.startswith(s) for s in _INFRA_SERVICES):
|
||||
return ("serviciu_infra", f"systemctl {verb} pe serviciul de infra {t}")
|
||||
|
||||
# --- git
|
||||
if exe == "git":
|
||||
verbs = [a for a in args if not a.startswith("-")]
|
||||
verb = verbs[0] if verbs else ""
|
||||
if verb == "push" and (
|
||||
_has_flag(args, "f", "--force", "--force-with-lease")
|
||||
or any(a.startswith("--force") for a in args)
|
||||
):
|
||||
return ("git_push_force", "git push --force rescrie istoria pe remote")
|
||||
if verb == "clean" and _has_flag(args, "f", "--force"):
|
||||
return ("git_clean", "git clean sterge fisiere neversionate")
|
||||
if verb == "reset" and "--hard" in args:
|
||||
return ("git_reset_hard", "git reset --hard arunca modificarile locale")
|
||||
|
||||
# --- docker
|
||||
if exe == "docker":
|
||||
if sub == "system" and "prune" in args:
|
||||
return ("docker_prune", "docker system prune")
|
||||
if sub == "volume" and "rm" in args:
|
||||
return ("docker_volume", "stergere volum docker")
|
||||
if sub in ("rm", "rmi") and _has_flag(args, "f", "--force"):
|
||||
return ("docker_rm", f"docker {sub} -f")
|
||||
|
||||
# --- permisiuni pe cai de sistem
|
||||
if exe in ("chmod", "chown", "chgrp") and _has_flag(args, "R", "--recursive"):
|
||||
for a in args:
|
||||
if a.startswith("/") and (a.rstrip("/") in _SENSITIVE_ROOTS or a == "/"):
|
||||
return ("perm_sistem", f"{exe} -R pe {a}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def classify(tool_name: str, tool_input: dict) -> tuple[str, str] | None:
|
||||
"""Punctul de intrare al clasificatorului. Doar Bash e analizat in v1."""
|
||||
if tool_name != "Bash":
|
||||
return None
|
||||
cmd = tool_input.get("command") if isinstance(tool_input, dict) else None
|
||||
if not isinstance(cmd, str):
|
||||
return None
|
||||
return classify_command(cmd)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ raspunsuri
|
||||
|
||||
def _emit(decision: str, reason: str) -> None:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": decision,
|
||||
"permissionDecisionReason": reason,
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _deny(reason: str) -> int:
|
||||
_emit("deny", reason)
|
||||
return 0
|
||||
|
||||
|
||||
def _allow(reason: str) -> int:
|
||||
_emit("allow", reason)
|
||||
return 0
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
try:
|
||||
d = pathlib.Path(os.environ.get("CLAUDE_DISCORD_DIR") or (pathlib.Path.home() / ".claude-discord")) / "logs"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
import time as _t
|
||||
|
||||
with open(d / "confirm_hook.log", "a", encoding="utf-8") as fh:
|
||||
fh.write(f"{_t.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _timeout_s() -> float:
|
||||
raw = os.environ.get("CLAUDE_DISCORD_APPROVAL_TIMEOUT")
|
||||
try:
|
||||
if raw:
|
||||
return max(1.0, float(raw))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return DEFAULT_TIMEOUT_S
|
||||
|
||||
|
||||
def run(payload_text: str) -> int:
|
||||
"""Logica hook-ului, separata de I/O ca sa poata fi testata."""
|
||||
try:
|
||||
payload = json.loads(payload_text)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload-ul nu e obiect JSON")
|
||||
except Exception as exc:
|
||||
return _deny(f"hook de confirmare: intrare invalida ({exc}); refuz din principiu")
|
||||
|
||||
tool_name = payload.get("tool_name") or ""
|
||||
tool_input = payload.get("tool_input") or {}
|
||||
verdict = classify(tool_name, tool_input)
|
||||
if verdict is None:
|
||||
return 0 # nepericuloasa: fara iesire, flux normal
|
||||
|
||||
rule, reason = verdict
|
||||
command = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
|
||||
|
||||
try:
|
||||
import approvals # noqa: PLC0415 - import tarziu, ca eroarea sa cada in deny
|
||||
except Exception as exc:
|
||||
return _deny(f"hook de confirmare: modulul de aprobari lipseste ({exc})")
|
||||
|
||||
timeout = _timeout_s()
|
||||
try:
|
||||
req = approvals.create_request(
|
||||
tool_name=tool_name,
|
||||
command=command,
|
||||
reason=reason,
|
||||
rule=rule,
|
||||
thread_id=os.environ.get("CLAUDE_DISCORD_THREAD_ID"),
|
||||
session_id=payload.get("session_id"),
|
||||
cwd=payload.get("cwd"),
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as exc:
|
||||
_log(f"DENY (cerere neputincioasa: {exc}) rule={rule} cmd={command[:120]}")
|
||||
return _deny(
|
||||
f"hook de confirmare: nu pot cere aprobarea ({exc}); "
|
||||
f"operatiune blocata ({reason})"
|
||||
)
|
||||
|
||||
rid = req["request_id"]
|
||||
_log(f"PENDING {rid} rule={rule} cmd={command[:160]}")
|
||||
try:
|
||||
decision = approvals.wait_for_decision_sync(rid, timeout)
|
||||
except Exception as exc:
|
||||
decision = "deny"
|
||||
_log(f"DENY {rid} exceptie la asteptare: {exc}")
|
||||
|
||||
if decision == "allow":
|
||||
approvals.finish_request(rid, "allow")
|
||||
_log(f"ALLOW {rid} rule={rule}")
|
||||
return _allow(f"aprobat in Discord (cerere {rid}, {reason})")
|
||||
|
||||
approvals.finish_request(rid, "deny")
|
||||
_log(f"DENY {rid} rule={rule}")
|
||||
return _deny(
|
||||
f"neaprobat in Discord in {int(timeout)}s (cerere {rid}, {reason}). "
|
||||
"Cere confirmarea si reia comanda."
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
payload_text = sys.stdin.read()
|
||||
except Exception as exc: # pragma: no cover - stdin rupt
|
||||
return _deny(f"hook de confirmare: nu pot citi stdin ({exc})")
|
||||
try:
|
||||
return run(payload_text)
|
||||
except Exception as exc: # plasa de siguranta finala
|
||||
return _deny(f"hook de confirmare: eroare interna ({exc}); refuz din principiu")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
192
proxmox/lxc171-claude-agent/discord-bridge/security/infra
Executable file
192
proxmox/lxc171-claude-agent/discord-bridge/security/infra
Executable file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""infra -- singura poarta prin care puntea Discord atinge infrastructura.
|
||||
|
||||
infra <host> <comanda...> ruleaza comanda pe hostul din lista
|
||||
infra --list arata hosturile permise
|
||||
infra --help
|
||||
|
||||
Hosturile sunt o lista EXPLICITA. Un host care nu e in lista este refuzat, fara
|
||||
incercare de rezolvare DNS. Fiecare apel este jurnalizat in
|
||||
~/.claude-discord/logs/infra.log cu data, host, comanda completa si rezultat.
|
||||
|
||||
Lista implicita poate fi inlocuita cu ~/.claude-discord/infra-hosts.json:
|
||||
|
||||
{"pvemini": {"addr": "10.0.20.201", "user": "root", "prod": true,
|
||||
"desc": "nod Proxmox principal"}}
|
||||
|
||||
Coduri de iesire proprii wrapper-ului (comenzile remote isi pastreaza codul lor):
|
||||
2 utilizare gresita (lipseste hostul sau comanda)
|
||||
3 host in afara listei
|
||||
4 eroare de configurare (fisier de hosturi corupt)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
EXIT_USAGE = 2
|
||||
EXIT_UNKNOWN_HOST = 3
|
||||
EXIT_CONFIG = 4
|
||||
|
||||
# Lista implicita, din tabelul de retea al repo-ului (CLAUDE.md, proxmox/README.md).
|
||||
DEFAULT_HOSTS: dict[str, dict] = {
|
||||
# noduri Proxmox
|
||||
"pve1": {"addr": "10.0.20.200", "user": "root", "prod": True, "desc": "nod Proxmox pve1"},
|
||||
"pvemini": {"addr": "10.0.20.201", "user": "root", "prod": True, "desc": "nod Proxmox principal"},
|
||||
"pveelite": {"addr": "10.0.20.202", "user": "root", "prod": True, "desc": "nod Proxmox pveelite"},
|
||||
# servicii interne
|
||||
"oracle": {"addr": "10.0.20.121", "user": "root", "prod": False, "desc": "LXC 108 Oracle XE 21c/18c"},
|
||||
"flowise": {"addr": "10.0.20.161", "user": "root", "prod": False, "desc": "LXC 104 Flowise"},
|
||||
"gitea": {"addr": "10.0.20.165", "user": "root", "prod": False, "desc": "LXC Gitea"},
|
||||
"docker": {"addr": "10.0.20.113", "user": "root", "prod": False, "desc": "LXC 102 Docker + Portainer"},
|
||||
"moltbot": {"addr": "10.0.20.173", "user": "root", "prod": False, "desc": "LXC 110 MoltBot"},
|
||||
"dokploy": {"addr": "10.0.20.167", "user": "root", "prod": False, "desc": "LXC 103 Dokploy"},
|
||||
# productie / clienti
|
||||
"oracle-prod": {"addr": "10.0.20.36", "user": "romfast", "prod": True, "desc": "server Oracle de PRODUCTIE"},
|
||||
"oracle-dr": {"addr": "10.0.20.37", "user": "romfast", "prod": True, "desc": "server Oracle DR"},
|
||||
"roacentral": {"addr": "10.0.20.122", "user": "romfast", "prod": True, "desc": "VM 201 Windows, IIS reverse proxy"},
|
||||
"oracle-test": {"addr": "10.0.20.130", "user": "romfast", "prod": False, "desc": "VM 302 mediu de test"},
|
||||
}
|
||||
|
||||
SSH_OPTS = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=10",
|
||||
]
|
||||
|
||||
|
||||
def state_dir() -> pathlib.Path:
|
||||
override = os.environ.get("CLAUDE_DISCORD_DIR")
|
||||
if override:
|
||||
return pathlib.Path(override)
|
||||
return pathlib.Path.home() / ".claude-discord"
|
||||
|
||||
|
||||
def hosts_file() -> pathlib.Path:
|
||||
return state_dir() / "infra-hosts.json"
|
||||
|
||||
|
||||
def log_file() -> pathlib.Path:
|
||||
return state_dir() / "logs" / "infra.log"
|
||||
|
||||
|
||||
def load_hosts() -> dict[str, dict]:
|
||||
"""Lista de hosturi: fisierul de pe disc daca exista, altfel cea implicita."""
|
||||
path = hosts_file()
|
||||
if not path.is_file():
|
||||
return dict(DEFAULT_HOSTS)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise SystemExit(_fail(EXIT_CONFIG, f"infra: {path} nu e JSON valid ({exc})"))
|
||||
if not isinstance(data, dict) or not data:
|
||||
raise SystemExit(_fail(EXIT_CONFIG, f"infra: {path} nu contine hosturi"))
|
||||
out: dict[str, dict] = {}
|
||||
for name, spec in data.items():
|
||||
if isinstance(spec, str):
|
||||
spec = {"addr": spec}
|
||||
if not isinstance(spec, dict) or not spec.get("addr"):
|
||||
raise SystemExit(_fail(EXIT_CONFIG, f"infra: intrare invalida pentru '{name}'"))
|
||||
out[str(name)] = {
|
||||
"addr": str(spec["addr"]),
|
||||
"user": str(spec.get("user") or "root"),
|
||||
"prod": bool(spec.get("prod", False)),
|
||||
"desc": str(spec.get("desc") or ""),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _fail(code: int, msg: str) -> int:
|
||||
sys.stderr.write(msg + "\n")
|
||||
return code
|
||||
|
||||
|
||||
def log(host: str, target: str, cmd: list[str], rc, dur_s: float, note: str = "") -> None:
|
||||
"""Jurnal pe o linie: data, host, comanda completa, rezultat."""
|
||||
line = (
|
||||
f"{datetime.datetime.now().isoformat(timespec='seconds')}\t"
|
||||
f"host={host}\ttarget={target}\trc={rc}\tdur={dur_s:.2f}s\t"
|
||||
f"cmd={shlex.join(cmd) if cmd else ''}"
|
||||
)
|
||||
if note:
|
||||
line += f"\tnote={note}"
|
||||
try:
|
||||
path = log_file()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(line + "\n")
|
||||
except OSError:
|
||||
pass # un jurnal care nu se poate scrie nu opreste comanda
|
||||
|
||||
|
||||
def usage(hosts: dict[str, dict]) -> str:
|
||||
lines = ["infra <host> <comanda...>", "", "Hosturi permise:"]
|
||||
for name, spec in sorted(hosts.items()):
|
||||
flag = " [PRODUCTIE]" if spec.get("prod") else ""
|
||||
lines.append(f" {name:<13} {spec['user']}@{spec['addr']:<13} {spec.get('desc','')}{flag}")
|
||||
lines.append("")
|
||||
lines.append(f"Lista se poate inlocui prin {hosts_file()}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
hosts = load_hosts()
|
||||
|
||||
if not argv or argv[0] in ("-h", "--help"):
|
||||
print(usage(hosts))
|
||||
return 0 if argv else EXIT_USAGE
|
||||
if argv[0] in ("-l", "--list"):
|
||||
print(usage(hosts))
|
||||
return 0
|
||||
|
||||
host = argv[0]
|
||||
cmd = argv[1:]
|
||||
|
||||
if host not in hosts:
|
||||
log(host, "-", cmd, "refuzat", 0.0, note="host in afara listei")
|
||||
return _fail(
|
||||
EXIT_UNKNOWN_HOST,
|
||||
f"infra: host necunoscut '{host}'. Hosturi permise: "
|
||||
+ ", ".join(sorted(hosts)),
|
||||
)
|
||||
if not cmd:
|
||||
return _fail(EXIT_USAGE, f"infra: lipseste comanda pentru '{host}'")
|
||||
|
||||
spec = hosts[host]
|
||||
target = f"{spec['user']}@{spec['addr']}"
|
||||
ssh_argv = ["ssh", *SSH_OPTS, target, "--", *cmd]
|
||||
|
||||
if os.environ.get("INFRA_DRY_RUN"):
|
||||
log(host, target, cmd, "dry-run", 0.0, note="INFRA_DRY_RUN")
|
||||
print(shlex.join(ssh_argv))
|
||||
return 0
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
proc = subprocess.run(ssh_argv)
|
||||
rc = proc.returncode
|
||||
except FileNotFoundError:
|
||||
log(host, target, cmd, "eroare", time.monotonic() - t0, note="ssh lipseste")
|
||||
return _fail(EXIT_CONFIG, "infra: `ssh` nu exista in PATH")
|
||||
except KeyboardInterrupt:
|
||||
log(host, target, cmd, "intrerupt", time.monotonic() - t0)
|
||||
return 130
|
||||
log(host, target, cmd, rc, time.monotonic() - t0)
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as exc: # nimic nu iese neraportat
|
||||
sys.stderr.write(f"infra: eroare interna: {exc}\n")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user