Korlátok és hevederek

Fékezzük meg a szabadon garázdálkodó AI modelleket

Képzelj el egy félkarú rablót. No nem egy végtaghiányos haramiát, hanem egy nyerőgépet. Meghúzod a kart, megjelenik rajta a nyerő 777 sorozat és a számítógépen lefut az a parancs, amire épp szükséged van. Viszont ha nem jön ki a nyerő kombináció... bármi megtörténhet. Valami oknál fogva mégis a kar ismételt meghúzása mellett döntesz, de érzed, hogy valahogy minimalizálni kellene a nem kívánatos mellékhatásokat.

Ilyesminek képzelem a saját gépeden bármilyen parancs lefuttatására képes AI modelleket és bár léteznek dobozos megoldások a féken tartásukra (például a Docker Sandboxes), nyilván építenünk kell egy sajátot, hogy kicsit jobban a dolgok mélyére ássunk.

Az ágens

Nagyjából minden nagyobb szolgáltatónak van valami harness-ként vagy agent-ként emlegetett TUI megoldása (Codex, Claude Code és a többi), ami képes mindenre is. Én valami egyszerűbbet szerettem volna, hogy minél kevesebb legyen a varázslat. Végül a Pi mellett döntöttem.

Az izolációról a Docker (vagy esetünkben Podman) fog gondoskodni. Itt lehet még a kényelem vs. biztonság csúszkával játszadozni, futhatna egy külön virtuális gépben is, de én inkább a kényelem felé hajlottam, remélve, hogy amire kell, arra elég biztonságos: egy operátor által felügyelt LLM esetleges hibás vagy nemkívánatos parancsainak negatív következményeit szeretnénk minimalizálni, nem egy teljesen autonóm és potenciálisan ellenséges szándékú ágenst szeretnénk kordában tartani.

A módszer pedig a jól megszokott legkisebb jogosultság elve. Csak ahhoz adjunk hozzáférést, amihez feltétlenül szükséges és csak annyit, amennyi szükséges:

  • ne kapjon írási jogot a teljes fájlrendszerhez, ha csak néhány fájlt kell olvasnia
  • ne legyen hozzáférése a teljes hálózathoz, ha csak előre meghatározott weboldalakat kell elérnie
  • ne kapja meg az összes környezeti változónkat
  • ne férjen hozzá a saját használatra szánt parancsainkhoz

Az első lépés elkészíteni a Pi számára a Dockerfile-t:

Dockerfile
FROM docker.io/library/node:24-alpine3.24

USER root

RUN apk add --no-cache bash curl fd git jq python3 ripgrep yq-python

USER node

RUN mkdir -p /home/node/src
WORKDIR /home/node/src

ARG PI_VERSION=1.4.0

RUN npm config set prefix "~/.local/" && \
    npm install -g --ignore-scripts @earendil-works/pi-coding-agent@${PI_VERSION}

ENV PATH="/home/node/.local/bin:$PATH"

Az alkalmazáson kívül néhány olyan projekt független parancs kerül bele az image-be, amiket gyakran használnak az LLM-ek. A rendszer promptban megkérhetjük a modellt, hogy jelezze, ha valamilyen eszköz hiányzik és ne próbálja meg telepíteni vagy más trükkökkel helyettesíteni. Aztán ez vagy sikerül vagy nem. Futottam bele olyan esetbe, hogy ennek ellenére is elkezdett C-ben leimplementálni valamit, amit nem talált meg, ahelyett, hogy megállt volna.

Az image-et aztán megépítjük és felvehetünk egy parancsot neki a .profile vagy hasonló fájlunkba, amivel el tudjuk indítani.

$ podman build -f Dockerfile -t localhost/pi-harness:latest .
function pi {
  podman run \
    --rm \
    --tty \
    --interactive \
    --volume "$HOME/.config/pi:/home/node/.pi:z" \
    --volume "$PWD:/home/node/$(basename $PWD):z" \
    --workdir "/home/node/$(basename $PWD)" \
    --userns keep-id \
    --read-only \
    --read-only-tmpfs \
    --network none \
    --cap-drop ALL \
    --security-opt no-new-privileges:true \
    --cpus 1 \
    --memory 512m \
    --pids-limit 64 \
    --stop-timeout 5 \
    localhost/pi-harness:latest \
    pi
}

Először is, a Pi megkapja a saját konfigurációs könyvtárát, valamit az aktuális könyvtárat, amiben éppen állunk, mert feltételezzük, hogy azzal szeretnénk dolgozni. Felhasználjuk az aktuális könyvtár nevét is, hogy nagyjából megfelelő chat előzményeket kapjunk. Ezeknek a könyvtáraknak a tartalmára tekintsünk úgy, mint amivel bármi megtörténhet, úgyhogy legyen megfelelő másolat bármiről, ami szívünknek fontos.

A fájlrendszer nagy része nem írható, csak a volume-ok és néhány ideiglenes könyvtár, mint a /tmp. Ezen kívül lemondunk az összes privilégiumról és az erőforrások is korlátozva lesznek, hogy a host gépet nehezebben lehessen térdre kényszeríteni.

Előfordulhat, hogy nem egy projekt kapcsán van szükségünk a Pi-re, vagy nem akarunk vele fájlokat megosztani, erre egy külön pi-ns parancsot tartok, ami a Pi-t a --no-session kapcsolóval indítja el és az aktuális könyvtár helyett egy random ideiglenes könyvtárat kap meg, amibe dolgozhat.

Ez eddig mind szép és jó, de van egy hatalmas nagy hibája. Nem ér el semmilyen hálózatot, így a Pi alkalmazás az LLM szolgáltató API-jával se tud beszélgetni.

Behálózva

Lehet mérlegelni, hogy akarunk-e teljes internet hozzáférést biztosítani a modell számára vagy valamiféle szűrőt alkalmazunk. Nézzük meg, hogy az utóbbi hogyan működhet. Kelleni fog hozzá két hálózat:

$ podman network create --driver bridge --internal pi-internal
$ podman network create --driver bridge pi-external

Az elgondolás az, hogy a Pi csak a belső hálózathoz fér hozzá és azon keresztül egy proxy-val fog beszélgetni, ami a külső hálózat segítségével el tudja érni az internetet. Ehhez a Pi podman run parancsában le kell cserélnünk a --network kapcsolót none-ról pi-internal-ra a hálózat eléréséhez és az --env http_proxy=http://proxy:3128 valamint az --env https_proxy=http://proxy:3128 kapcsolókat hozzá kell adnunk a proxy beállításához.

A proxy egy másik Docker image lesz, amiben egy squid-et futtatunk:

Dockerfile.proxy
FROM docker.io/library/alpine:3.24

RUN apk add --no-cache squid

COPY squid.conf /etc/squid/squid.conf

CMD ["squid", "-N"]

Az említett konfigurációs fájl pedig valahogy így nézhet ki:

squid.conf
acl localnet src 10.0.0.0/8
acl safe_ports port 80 443
acl ssl_ports port 443

acl allowed_hosts dstdomain -n example.org

http_access deny !localnet
http_access deny !safe_ports
http_access deny CONNECT !ssl_ports
http_access deny manager
http_access deny to_localhost
http_access deny to_linklocal

http_access allow localnet allowed_hosts

http_access deny all

http_port 3128

cache deny all

logfile_rotate 0
cache_log stdio:/dev/stdout
access_log stdio:/dev/stdout
cache_store_log stdio:/dev/stdout
pid_filename /tmp/squid.pid
shutdown_lifetime 2 seconds

Példának egy olyan konfigurációt hoztam, ami csak az allowed_hosts listán szereplő oldalakra enged tovább, de akár arra is használhatjuk a proxy-t, hogy csak logoljuk a kimenő forgalmat.

Amit érdemes észben tartani, hogy ezzel csak azt tudjuk korlátozni, hogy kivel kommunikálhat a modell, azt nem, hogy milyen adatok kerülnek elküldésre a gépünkről külső szolgáltatók felé.

A proxy-t egy, a Pi-hez hasonló paranccsal tudjuk elindítani:

$ podman build -f Dockerfile.proxy -t localhost/pi-proxy:latest .
function pi-proxy {
  podman run \
    --rm \
    --user squid \
    --cap-drop ALL \
    --security-opt no-new-privileges:true \
    --cpus 1 \
    --memory 512m \
    --pids-limit 64 \
    --stop-timeout 5 \
    --network pi-internal \
    --network pi-external \
    --network-alias proxy \
    --read-only \
    --read-only-tmpfs \
    --replace \
    --detach \
    --name pi-proxy \
    localhost/pi-proxy:latest
}

Ezen a ponton a Pi már tud beszélgetni a szolgáltató API-jával, eléri az internetet, látja és módosítani tudja a projektünk fájljait, de mi történik ha mondjuk egy PHP nyelven írt projekten kell dolgoznia?

A Pi Docker image-ében nincsenek meg a megfelelő eszközök és nem is biztos, hogy minden lehetséges programozási nyelv minden lehetséges verzióját bele szeretnénk telepíteni.

A projekt környezet

Élek a feltételezéssel, hogy a projekteket is Docker-ben futtatjuk, így már létezik egy konténer, amiben meg tudnánk futtatni a megfelelő parancsokat. A kérdés csak az, hogy hogyan. Az én ötletem a proxy-hoz hasonlatos, egy HTTP szerver, ami egyfajta Remote Code Execution as a Service-ként működne, azaz az általunk beküldött parancsokat lefuttatja a saját környezetében és visszaadja a válaszukat.

A projekt környezet is használhatja a belső hálózatot

Szóval indítunk egy konténert, ami ugyanazt az image-et használja, amit a projekt is, rajta van a Pi belső hálózatán (vagy van egy saját hálózat projektenként, ha több projektet is futtatunk egyszerre és nem akarjuk, hogy minden Pi lásson minden projekt környezetet) és fut benne egy szerver, ami lefuttat bármilyen kívülről jött parancsot. Mi baj történhet.

Projektenként külön hálózat, hogy az ágensek ne lássanak rá egymás projektjére

Két dolog véd minket, az egyik, hogy csak az tud parancsokat beküldeni, aki rajta van a megfelelő hálózaton (ne ajánljuk ki ezt a szolgáltatást például a host gépnek vagy az internetnek), a másik pedig az, hogy a parancs futtató konténert is megfelelőképpen le fogjuk korlátozni.

A projekt Dockerfile részleteitől eltekintenék, nincs különösebb jelentősége, a hozzá tartozó, nagyon minimális Docker Compose konfigurációról is elég ennyi:

docker-compose.yml
services:
  app:
    build: .
    userns_mode: keep-id
    volumes:
      - .:/home/app/src:z
    command: ["/bin/true"]

A saját, Pi-vel kapcsolatos konfigurációnk lehet ennek a fájlnak a része, ha nem zavar másokat, akik szintén ezen a projekten dolgoznak, de ha meg akarjuk tartani magunknak, akkor érdemes az override fájlba rakni:

docker-compose.override.yml
volumes:
  pi-shared:
    external: true
networks:
  pi-internal:
    external: true
services:
  executor:
    build: .
    userns_mode: keep-id
    volumes:
      - .:/home/app/src:z
      - pi-shared:/home/app/shared:z,ro
    command: ["/home/app/shared/executor"]
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    read_only: true
    cpus: 2
    mem_limit: 2048m
    pids_limit: 128
    stop_grace_period: 5s
    environment:
      http_proxy: http://proxy:3128
      https_proxy: http://proxy:3128
    networks:
      pi-internal:
        aliases: ["test-project"]

A konfiguráció egy része ugyanaz, mint amit az app is használ, de hozzá jönnek azok a korlátozások, amiket a Pi elindításánál is használtunk és ez a konténer is csak a proxy-n keresztül érheti el az internetet. Ezek nélkül a korlátozások nélkül lenne egy gyengébb láncszem a modell által elérhető konténerek között, amit aztán akarva vagy akaratlanul ki is használhatna.

Két dolog még, amit érdemes megemlíteni, az első a network részben az aliases, ahol meg kell adnunk egy egyedi nevet, ezt fogjuk később használni a projekt szerver elérésére, például: http://test-project:8080.

A másik dolog pedig az extra, csak olvasható Docker volume (pi-shared). Itt lesz elérhető a szerverünk, ami lefuttatja majd a parancsokat. Érdemes valami hordozható szervert választani, mint például egy statikusan linkelt Go binárist, hogy minél több projektben tudjuk használni.

A szerver kódja nem különösebben érdekes, egy megfelelő modellel pillanatok alatt le lehet generáltatni. Egy nagyon egyszerű HTTP szerverről van szó, egy darab POST endpoint-tal (/execute), ami a JSON formában érkező parancsot lefuttatja és a kimenetét visszaküldi válaszban. A binárist egy Dockerfile-ban építem meg, utána pedig átmásolom a megfelelő volume-ba:

$ podman volume create pi-shared
$ podman build -f Dockerfile.executor -t localhost/pi-executor:latest .
$ podman run --rm -v pi-shared:/shared localhost/pi-executor:latest sh -c 'cp /build/executor /shared/executor'

Már csak a Pi-nek kell megmondani, hogy hogyan tudja ezt a szervert használni. És ez esetben most tényleg arról van szó, hogy megmondhatjuk neki. Például elküldhetjük azon dolgozni, hogy csináljon egy skill-t, ami a projekt-specifikus parancsokat (például tesztek futtatása, a projekt függőségek telepítése és a többi), az executor service-ben futó szerveren keresztül futtatja meg, amit a projekt docker-compose.override.yml fájljában talál, a szerver neve network alias-ként van megadva és mindig a 8080-as porton fut.

Én egy kicsit kevesebb mozgásteret hagytam neki, kézzel írtam meg a shell script-et, amit hívnia kell és csak a skill szövegét írattam meg vele:

execute.sh
#!/bin/sh

if ! [ -f 'docker-compose.override.yml' ]; then
    echo 'Cannot run command, application environment is not configured.'
    exit 255
fi

host_name=$(yq -r '.services.executor.networks["pi-internal"].aliases[0]' docker-compose.override.yml 2>/dev/null)
if [ "$host_name" = "null" ]; then
    echo 'Cannot run command, application environment is not configured.'
    exit 255
fi

json=$(jq -nc '$ARGS.positional' --args -- "$@")

response=$(http_proxy= curl -s --json "{\"command\":$json}" "http://$host_name:8080/execute")
if [ $? -gt 0 ]; then
    echo 'Application environment is not running, notify the operator.'
    exit 255
fi

exitCode=$(echo "$response" | jq -r .exitCode)
output=$(echo "$response" | jq -r .output)

echo "$output"
exit $exitCode

Feltételezzük (vagy ki is köthetjük), hogy ez a parancs a projekt könyvtárából lesz meghívva, így az ott található docker-compose.override.yml fájlból ki tudjuk olvasni a megadott network alias-t, összerakjuk a megfelelő curl parancsot és prezentáljuk a választ úgy, mintha a shell script saját kimenete lett volna. Érdemes még megjegyezni, hogy a globálisan beállított proxy-t ehhez a curl híváshoz ki kell ütnünk, mivel egy, a Pi belső hálózatán lévő dologgal szeretnénk beszélgetni, nem pedig valamivel az interneten.

Lezárás

Ennyi viszontagság után egy hasonlóan egyszerűen használható rendszert kapunk, mintha csak direktben a saját felhasználónkkal futtatnánk az ágenst. A projekt könyvtárában állva csak elindítjuk a projekt konténereit, kiadjuk a pi-proxy és pi parancsokat és kezdődhet is a munka. Persze esnek majd ki menet közben hiányzó parancsok vagy weboldalak, amiket fel kell vennünk a proxy konfigurációjába, esetleg egy teljesen új szolgáltatást kell írnunk majd a Pi belső hálózatára, ami csak korlátozott hozzáférést biztosít valamihez, de valahol meg kell fizetni a biztonság (vagy biztonság hiányának) árát.

A veszély azért továbbra is jelen van, csak kisebb a hatásköre. Továbbra is oda kell figyelnünk, hogy milyen adatokat osztunk meg a modellel (egy ottfelejtett .env fájl éles titkokkal a projekt könyvtárában), és azt is érdemes nyomon követni, hogy a modell milyen változtatásokat eszközöl a projekten belül (amik esetleg a saját jogosultságainkkal futtatva okozhatnak nem várt mellékhatásokat). De legalább már nem arról van szó, hogy a saját felhasználónk nevében elindított Claude Code a saját használatra szánt gcloud parancsunkkal letörölheti a teljes éles infrastruktúránkat, ha épp ilyen gondolatai támadnak.

This post is also available in English: Harnesses and guardrails

Hozzáfűznél valamit?

Dobj egy emailt a blog kukac deadlime pont hu címre.

Feliratkoznál?

Az RSS feed-et ajánljuk, ha kedveled a régi jó dolgokat.