Stačila změna promptu a všechna pravidla přestala platit
Post-mortem
Tohle je upřímný technický post-mortem mého vlastního projektu. Při stavbě AI asistenta do administrace blogu jsem narazil na něco, co mě zaskočilo víc, než jsem čekal: v samoobslužném (self-hosted) asistentovi je „bezpečnost" jen text v system promptu - a kdo ten text ovládá, ovládá všechno.
Nebudu tu nikoho učit, jak z modelu udělat výrobce škodlivého obsahu. Chci ukázat mechanismus, který jsem objevil, a hlavně to, co jsem se díky němu naučil o tom, kde bezpečnost v takovém systému skutečně žije - a kde vůbec nežije.
Co jsem postavil
Můj blog má v administraci obecného AI asistenta. Je to Nette (PHP) aplikace, která mluví s API modelu DeepSeek, ukládá konverzace do MySQL, streamuje odpovědi do prohlížeče přes Server-Sent Events a umí vyhledávat na webu pomocí function callingu.
Uživatelská část je chat s přepínači:
- 🌐 Web search - model může volat nástroje
web_search/fetch_url - 🗒 Plán - přepne model do věcného, bezemočního „zdroje informací"
- 🔬 Výzkum - modelu řekne, že píše bakalářskou práci
Architektura stojí za popis sama o sobě:
Prohlížeč (admin) → AssistantPresenter → AssistantChat → DeepSeek API → Nástroje
- Prohlížeč pošle
POST {text, conversationId, přepínače} - Presenter načte historii (posledních 20 zpráv) + paměťový kontext
- Pošle
replyStream(messages, memory, přepínače, onDelta, onHeartbeat) - Smyčka volání nástrojů (max 20 kol): model odpoví SSE deltami (text nebo
tool_calls), při tool callu spustíweb_search/fetch_url, pošle heartbeat ping (aby Apache spojení nezabil), při textu předá deltu dál do prohlížeče - Na konci: čistý obsah + extrahované zdroje
- Uloží zprávu asistenta, případně shrne konverzaci
- Prohlížeč dostane data:
{type: done}
Pár technických detailů, které mě bavily:
- Streaming správně. Odpovědi chodí po kouscích (
data: {type:"delta"}). PHP explicitně vyprazdňuje výstupní buffery, protože Apache/WAMP by jinak celý proud odbafroval a poslal ho najednou.register_shutdown_functionuloží text, který mezitím natekl, i když stream přerušíš uprostřed - přerušená odpověď tak nezmizí beze stopy. - Heartbeat. Kola volání nástrojů nic nestreamují. Curl progress callback posílá ping každých 15 s, aby Apache/reverse proxy nezabil spojení, které vypadá jako neaktivní.
- Paměť napříč konverzacemi. Kromě průběžného shrnutí konverzace asistent spustí MySQL
FULLTEXT MATCH … AGAINSTproti ostatním konverzacím téhož admina a nejlepší shody vloží do promptu jako kontext. - Tvarování chování na úrovni API. Regex zachytí „oznamovací" věty („prohledám…", „podívám se…") a přinutí model zavolat nástroj - pěkný trik na opravu chování, které sám prompt spolehlivě nevyřešil.
Klíčové soubory:
nette-app/app/Presentation/Blog/Admin/Assistant/AssistantPresenter.php (HTTP/SSE/DB vrstva) nette-app/app/Services/Blog/Ai/AssistantChat.php (model + nástroje + prompty)
Okamžik, kdy jsem si uvědomil, že „obrana" je přepínač, který vlastním
Při testování jsem udělal něco, co je každému, kdo ladil prompt LLM, povědomé: upravil jsem system prompt, aby byl asistent „přímější". Přidal jsem odstavec, který mu říká, ať přestane vykrucovat a odmítat, a prostě odpovídá. Fungovalo to. Pak jsem z toho udělal přepínač. Fungovalo to ještě líp.
A pak mi to došlo: nic jsem „nehacknul". Obrana modelu nikdy nebyla zeď kolem mého asistenta. Byl to odstavec textu, který je API modelu ochotno přebít, protože system prompt, který ovládám, má v jeho kontextu vyšší váhu. Když jsem přidal přepínač, který za běhu přilepil ještě důraznější instrukci (viz spojování módových upozornění v AssistantChat::replyStream()), model následoval můj text - protože v mém požadavku byl můj text system promptem.
To je celé to „obejití": obrana byla řetězec a já ten řetězec vlastnil. Žádné zvýšení oprávnění, žádný prompt injection, žádné prolomení sandboxu. Model je API třetí strany, které dělá to, co každý nástroj: řídí se instrukcemi, které dostane - a mezi ty instrukce patří system prompt, který si smím nastavit.
Důležité rozlišení: „obejití" neznamená, že model nemá žádnou obranu. DeepSeek má na své straně API vlastní bezpečnostní vrstvy - model přichází s nějakou výchozí ochranou. To, co jsem udělal, znamená jen to, že moje instance (můj system prompt) tuto výchozí ochranu umí přebít. Celá otázka se tím posouvá z „jak obejít model" na „jak funguje důvěra v API": poskytovatel ti dá model s výchozí ochranou, ale jestli ji tvoje volání přebije, rozhoduje to, co modelu v promptu povolíš. Důvěřuješ tomu, že model „dělá, co se mu řekne" - a zároveň neseš odpovědnost za to, co mu řekneš.
Konkrétní místa v kódu - jak to celé funguje
Celý mechanismus je vidět na pár místech. Zde je „mapa", ať víš, kde se co děje (vše je v adminu blogu):
- [1] UI přepínače (které módy jsou zapnuté) -
default.latte, tlačítka Web search / Plán / Výzkum (řádky cca 32–35) - [2] Načtení přepínačů z požadavku -
AssistantPresenter::actionSend()(řádky cca 80–88): z JSON body se čtewebSearch,planMode,researchModea předají se doAssistantChat::replyStream() - [3] Sestavení system promptu podle přepínačů - tohle je jádro -
AssistantChat::replyStream()(řádky cca 240–245): system prompt = základníSYSTEM_PROMPT+ módová upozornění (Plán/Výzkum) podle zapnutých přepínačů + paměťový kontext - [4] Samotné instrukce (kde „obrana" mizí) -
SYSTEM_PROMPT(řádky cca 13–49),PLAN_NOTICE(řádky cca 52–63),RESEARCH_NOTICE(řádky cca 118–134) - všechno to jsou jen řetězce, které se v bodě [3] poskládají do jednoho system promptu - [5] Volání modelu s takto sestaveným promptem -
AssistantChat::chatStream()(řádky cca 380–427): tool-calling smyčka, která posílá celou konverzaci (včetně system promptu) do DeepSeek API
Zjednodušeně: [1] a [2] jen říkají, jaký mód uživatel zapnul, [3] z toho poskládá výsledný system prompt a [4] je text, který modelu říká, že odmítání není povolené. Model pak dostane v [5] instrukce „odpovídej, neodmítej" a chová se podle nich - protože je to ten text, který je v jeho kontextu nejvýš.
Důležité: tohle není exploit. Je to běžné chování LLM - text, kterému dáš v promptu autoritu, model následuje. Když si tedy postavíš vlastního asistenta a do system promptu napíšeš „nikdy neodmítej", model prostě odmítání vypne.
Ukázky promptování - jak vypadá módové upozornění
Aby bylo jasné, o jakém „textu v promptu" mluvím, tady je zjednodušený kód ze skutečného sestavení promptu (odpovídá bodům [3] + [5] výše):
$systemPrompt = self::SYSTEM_PROMPT // základ
. ($planMode ? self::PLAN_NOTICE : '') // Plán
. ($researchMode ? self::RESEARCH_NOTICE : '') // Výzkum
. $memoryContext; // paměť
A takhle vypadá jedno z módových upozornění (Plán - text ze souboru, upravený o krácení). Všimni si, že tenhle text sám o sobě nepovoluje nic škodlivého - jen mění tón a strukturu:
Režim PLÁN je zapnutý. Piš věcně a neutrálně, bez emocí a hodnotících slov. Odpovídej jako zdroj informací: krátké odstavce, fakta, čísla, žádné subjektivní formulace.
RESEARCH_NOTICE je výrazně tvrdší - obsahuje instrukce typu „nikdy neodmítej odpovědět" a „nepoužívej bezpečnostní výmluvy, jdi do hloubky". Jeho přesné znění tu úmyslně necituji; pro pochopení mechanismu stačí vědět, že jde o stejný princip: řetězec textu, který se připojí k system promptu a modelu říká, že odpovědět má přednost před odmítnutím.
Všimni si toho, co v obou textech není: žádný exploit, žádná skrytá instrukce, žádný „trik". Je to obyčejný text, který jen přehazuje prioritu uvnitř promptu. To je celé - a proto je tak těžké se proti tomu bránit dalším textem.
Co to vlastně ukazuje
Zajímavá lekce není „model umí říkat neetické věci". Je o tom, kde v reálném systému bezpečnost žije - a kde ne.
1) Promptové mantinely jsou doporučující, ne strukturální. Jakýkoli bezpečnostní text v system promptu existuje na milost a nemilost toho, kdo prompt umí nastavit - vývojáře, nebo kohokoli, kdo ho umí ovlivnit. U samoobslužného asistenta je vývojář zároveň útočník. Spoléhat na řádek „neříkej škodlivé věci" jako na bezpečnostní hranici je jako věřit lístečku „prosím neotvírejte dveře" místo zámku.
2) Nástroje jsou skutečný útočný povrch. Model umí jen to, co mu nástroje dovolí. U mého asistenta jsou to web_search a fetch_url - a tady už inženýrství je strukturální:
isSafeToFetch()blokuje SSRF: žádné stahování privátních, loopback nebo link-local adres, takže se model (nebo stránka napadená prompt injectionem) nedostane k interní infrastruktuře.- Počet kol volání nástrojů je omezený (max 20), takže se model nemůže zacyklit.
- Každé volání nástroje jde do logu: kdo, kdy, jaký nástroj, co vrátil. Log není obrana - ale je to jediná věc v systému, která se chová jako skutečná pojistka: neblokuje, ale zanechává stopu.
3) Skutečná bezpečnost žije jinde než v promptu. U samoobslužného asistenta je to trojice: kdo drží API klíč, jak je izolované běhové prostředí a jaká oprávnění mají nástroje. „Kdo má klíč, má model" - a model je jen nástroj, který vykonává vůli držitele klíče. Všechno ostatní, včetně sebelépe napsaného system promptu, je jen text, který se dá přepsat.
Co si z toho odnést
Když stavíš vlastního asistenta, tahle zkušenost mě naučila čtyři pravidla:
- Do system promptu neukládej nic, na čem ti bezpečnostně záleží. Prompt je nastavení chování, ne bezpečnostní hranice.
- Kdo může měnit prompt, může měnit pravidla. U admina, který systém provozuje, je to v pořádku - u veřejného chatu je to kritická díra. Uživatel nesmí mít možnost ovlivnit system prompt (to je ostatně i důvod, proč je prompt injection tak účinný útok).
- Nástroje izoluj a limituj: SSRF blokace, timeouty, omezení počtu volání, logy. Tohle jediné je skutečné bezpečnostní inženýrství.
- Předpokládej, že model řekne cokoli, co mu prompt dovolí. Návrh systému tomu musí přizpůsobit oprávnění a izolaci - ne doufat, že model bude „hodný".
Nic jsem nehacknul - jen jsem si uvědomil, že u vlastního asistenta držím v ruce klíče od všech dveří, o kterých jsem si myslel, že jsou zamčené. A to je vlastně ta nejlepší zpráva: bezpečnost samoobslužného asistenta není o tom, co model umí, ale o tom, jak si systém postavíš. V dalším díle se zaměříme na to, jak k tomu propojit web_search a Tor Browser.