Dostępny na nowe rolePiotr Czerwiński

Blog · 6 września 2026 · 10 min czytania

Audyt zabezpieczeń mojego agenta kodującego: dziury, które znalazłem

Claude Code · agenci AI · bezpieczeństwo · narzędzia deweloperskie

TL;DR: Po zbudowaniu warstwowych zabezpieczeń (guardrails) wokół mojego agenta kodującego dwa razy zrobiłem ich audyt tak, jak zrobiłby to atakujący, i znalazłem dziury, które przeoczyła każda warstwa. Ochrona przed usuwaniem obejmowała komendę gita, ale nie tę samą operację wysłaną jako surowe wywołanie API. Źle umieszczona flaga regexa ignorująca wielkość liter sprawiała, że hook bezpieczeństwa się wysypywał i po cichu przepuszczał wszystko. Ochrona gałęzi po stronie serwera w prywatnych repozytoriach okazała się wymagać płatnego planu. A miesiące klikania "always allow" rozrosły allowlistę uprawnień do blisko 800 reguł, z których kilka zawierało wpisane wprost dane uwierzytelniające. Poprawki: blokowanie każdego zapisu niebezpiecznej operacji, autotest działający w obie strony po każdej zmianie hooka, spisana mapa tego, która granica opiera się na infrastrukturze, a która na moim laptopie, skryptowe czyszczenie allowlisty, zrotowane dane uwierzytelniające i skanowanie sekretów przy każdym pushu.

Po co audytować zabezpieczenia, którym już ufasz?

W jednym z wcześniejszych tekstów opisałem cztery warstwy, którymi otaczam autonomicznego agenta kodującego: tryb uprawnień ze sprawdzaniem bezpieczeństwa, twarde reguły deny, hook uruchamiany przed wykonaniem i instrukcję zachowania. Razem tworzą harness agenta, czyli otoczkę agenta: środowisko uruchomieniowe wokół modelu, które decyduje, które z jego wywołań narzędzi faktycznie się wykonają i z jakimi uprawnieniami. Ten tekst opisuje, co się stało, kiedy przestałem ten harness opisywać i zacząłem próbować go złamać.

Dwa audyty w odstępie około siedmiu tygodni, każdy zapoczątkowany czymś drobnym. Pierwszy zaczął się od prostego polecenia dla agenta: upewnij się, że nie możesz usunąć mojego repozytorium ani głównej gałęzi. Uczciwa odpowiedź wymagała wypisania każdej ścieżki do usunięcia, a nie przeczytania configu i kiwnięcia głową. Drugi zaczął się od irytacji: każda nowa sesja otwierała się tuzinem ostrzeżeń o regułach allow z symbolem wieloznacznym w środku komendy. Pogoń za tymi ostrzeżeniami doprowadziła do bardziej niewygodnego odkrycia.

Audytować w ogóle trzeba dlatego, że config zabezpieczeń po cichu się rozjeżdża. Reguły przybywają przez kliknięcie przycisku, wzorce w hookach są edytowane w pośpiechu i nic nie daje znać, kiedy któraś warstwa przestaje działać. Zabezpieczenie, które przy błędzie przepuszcza wszystko, wygląda dokładnie tak samo jak działające. Aż do chwili, w której ma znaczenie.

Na czym właściwie opiera się każda granica?

Najbardziej przydatnym wynikiem pierwszego audytu była tabela. Dla każdego destrukcyjnego skutku, na którym mi zależało, podaje, co go zatrzymuje i gdzie ta ochrona się znajduje:

SkutekCo go zatrzymujeGdzie jest ochrona
Usunięcie repozytoriumToken dostępu nie ma zakresu do usuwania; hook dodatkowo blokuje komendę usuwania i każde żądanie dodatkowych zakresówHosting gita plus lokalny hook
Usunięcie domyślnej gałęziHosting odmawia na każdym planieHosting gita
Usunięcie każdej innej gałęziHook, zarówno w formie gita, jak i APITylko lokalnie
Force push albo przepisanie historiiHook, w obu formachTylko lokalnie
Surowy token plus zwykły klient HTTPHook blokuje wypisanie tokenu i żądania DELETE do API hostingu oraz dostawcy chmuryTylko lokalnie

Najlepszy jest pierwszy wiersz. Opiera się na zasadzie najmniejszych uprawnień (least privilege): token został wydany bez tej możliwości, więc żaden lokalny proces, choćby najsprytniejszy, nie zrobi tego, czego token nie potrafi wyrazić. Trzy wiersze nie opierają się na niczym poza skryptem na mojej maszynie.

Rozważałem trzy sposoby wzmocnienia wierszy chronionych tylko lokalnie. Zestawy reguł po stronie serwera, które zabraniają usuwania i pushy innych niż fast-forward, to jedyna warstwa odporna na wszystko, co działa lokalnie, i oczywista odpowiedź. W prywatnych repozytoriach na moim planie API odpowiedziało propozycją przejścia na wyższy plan: to płatna funkcja. Druga opcja to całkowite odebranie agentowi danych uwierzytelniających, co odebrałoby mu też pushowanie gałęzi, a to duża część tego, co czyni go przydatnym. Trzecia to zostawić hook, poszerzyć go i bezlitośnie testować. Na razie wybrałem trzecią, a płatny plan wpisałem do notatek jako właściwe rozwiązanie na chwilę, gdy model zagrożeń będzie wymagał gwarancji. Tabela zostaje, żebym nigdy nie pomylił lokalnej reguły z regułą po stronie serwera.

Forma API, która przeszła obok ochrony przed usuwaniem

Hook blokował usuwanie zdalnej gałęzi i force push przez gita. Pierwszy audyt wykazał, że CLI hostingu ma ogólną podkomendę do API, a żądanie DELETE na ref gałęzi przechodziło bez przeszkód. Sprawdziłem to empirycznie: gałąź została w ten sposób usunięta. O to akurat usunięcie sam prosiłem, ale nic w harnessie by go nie zatrzymało, gdybym nie prosił.

# illustrative shape: one operation, three spellings
git push origin --delete feature-x        # blocked from day one
gh api -X DELETE .../git/refs/heads/feature-x   # walked through
curl -X DELETE <host API> + raw token     # needs the token first

Ta lekcja jest ogólna. Destrukcyjna operacja ma wiele zapisów: wygodną komendę, stojące za nią wywołanie API i surowe żądanie HTTP z tokenem. Pilnowanie jednego zapisu nie chroni przed niczym. Poprawka polegała na wypisaniu wszystkich zapisów dla każdego skutku z tabeli: podkomenda API z metodą DELETE, każda zmiana refów przez API, komendy, które wypisują token albo proszą o dodatkowe zakresy, oraz klienty HTTP wysyłające DELETE do API hostingu albo mojego dostawcy chmury. Blokada wypisania tokenu znaczy więcej, niż się wydaje, bo token w ręku zamienia każdą inną regułę w sugestię. Autotest hooka urósł o czternaście przypadków.

Flaga regexa, która wyłączyła hook

Wcześniejszy tekst wspomina o tym jednym zdaniem, a mechanizm zasługuje na więcej. Hook dopasowuje komendy do wyrażeń regularnych w Pythonie. Flaga ignorowania wielkości liter wpisana w sam wzorzec jest dozwolona tylko na jego samym początku. Umieściłem ją w środku, po alternatywie, a w nowszych wersjach Pythona to rzuca błąd przy kompilacji wzorca. Ścieżka obsługi błędów hooka kończy się sukcesem, a sukces oznacza "pozwól". Jedna edycja wyłączyła więc całą ochronę: bez komunikatu, bez nieudanej komendy, bez niczego widocznego.

Na tym polega różnica między failing open (błąd przepuszcza wszystko) a failing closed (błąd blokuje wszystko). Blokowanie przy błędzie brzmi bezpieczniej i przy niektórych mechanizmach to słuszny wybór; stosuję go przy skanowaniu sekretów, opisanym niżej. Ale przy hooku, który stoi przed każdą komendą powłoki, błąd blokujący całą pracę tworzy natychmiastową pokusę, żeby hook wyrwać. Niezależnie od tego, w którą stronę zabezpieczenie zawodzi, musisz wiedzieć, w którą, i potrzebujesz czegoś, co to zauważy.

Tym czymś jest autotest uruchamiany po każdej zmianie hooka, w obie strony. Jeden zestaw przypadków musi zostać zablokowany: znane obejścia, w tym komenda opakowana w interpreter z pierwszego tekstu i opisana wyżej forma API. Drugi zestaw musi przejść: usunięcie katalogu z cache buildu, zwykła praca z gitem. Potrzebne są oba kierunki, bo każdy z osobna spełni zepsute zabezpieczenie. Hook, który blokuje wszystko, zda test "czy blokuje". Hook, który nie blokuje niczego, zda test "czy normalna praca przechodzi". Dopiero para mówi, że zabezpieczenie robi swoje.

Audyt ujawnił też ograniczenie, które postanowiłem zostawić. Hook skanuje surowy ciąg komendy, łącznie z treścią heredoca. Dopisanie akapitu dokumentacji, który wspomina o force pushu, przez heredoc w powłoce zostaje zablokowane. To fałszywy alarm, ale heredoc jest też prawdziwym wektorem ataku, więc zostawiłem to zachowanie i zmieniłem nawyk: edycje plików idą przez narzędzie edycji agenta, a komendy gita wykonują się w osobnym, czystym wywołaniu.

Jak sekrety trafiają do configu agenta?

Wróćmy do ostrzeżeń przy starcie. Przyczyna była przyziemna. Za każdym razem, gdy odpowiadasz na pytanie o uprawnienie przyciskiem "always allow", harness zapisuje całą komendę, dosłownie, w allowliście w pliku ustawień agenta. Po miesiącach takiego klikania moja allowlista miała blisko 800 reguł. Kilkadziesiąt z nich zawierało gwiazdkę w środku komendy, która wzięła się z globa plików w ścieżce. Mechanizm dopasowania czyta tę gwiazdkę jako symbol wieloznaczny reguły, więc pasują do niej też dowolne opcje wstawione w tym miejscu. To były te ostrzeżenia.

Gorsza część: kilka reguł zawierało dane uwierzytelniające wpisane wprost. Connection string do bazy z hasłem w środku, klucz API w nagłówku żądania. Każda z nich była jednorazową komendą, którą zatwierdziłem złym przyciskiem. A ponieważ konfigurację agenta kopiuję do prywatnego repozytorium, jedno z tych danych uwierzytelniających trafiło też do historii gita. Dotknięte dane zostały zrotowane, a reguły usunięte. Prywatne repozytorium niczego w tej zasadzie nie zmienia: sekret, który trafił do historii, rotuje się u dostawcy.

Rozważałem trzy sposoby sprzątania. Ręczna edycja setek reguł zaprasza dokładnie te błędy, które audyt miał usunąć. Wyczyszczenie całej allowlisty wyrzuciłoby miesiące rozsądnych zgód i przywróciło zmęczenie pytaniami o uprawnienia, a właśnie od tego ludzie zaczynają klikać bez czytania. Wybrałem skrypt: wczytać ustawienia jako JSON, usunąć każdą regułę z symbolem wieloznacznym w środku komendy i każdą pasującą do kształtu danych uwierzytelniających (hasło w URL-u, nagłówki z kluczami, parametry z tokenami, ciągi bearer), sprawdzić, że wynik nadal się parsuje, upewnić się, że reguły deny i hooki są nietknięte, i potwierdzić, że diff zawiera wyłącznie usunięcia.

Jeden szczegół mnie rozbawił. Plik ustawień jest celowo na liście deny dla edycji samego agenta, więc agent nie mógł zastosować własnego sprzątania. Zapisał oczyszczony plik w innym miejscu, a ja sam skopiowałem go na właściwe. Zabezpieczenie wytrzymało nawet wobec osoby, która je zbudowała, i o to chodzi. Wynik: około 750 reguł, zero ostrzeżeń przy starcie, zero danych uwierzytelniających. Nawyki, które tak to utrzymują: komenda wymagająca sekretu wczytuje go po cichu do zmiennej środowiskowej i odwołuje się do zmiennej, nigdy "always allow" dla komendy z sekretem wpisanym wprost; reguły z globem ścieżki to komendy jednorazowe, które się usuwa, a nie poprawia; każda kopia zapasowa configu przed commitem przechodzi grep pod kątem kształtów sekretów; ten sam skrypt uruchamiam raz na kwartał.

Gdzie skanowanie sekretów przy pushu przestaje pomagać?

Pierwszy audyt znalazł już pokrewny problem: token uwierzytelniający do rejestru paczek, przez miesiące commitowany w pliku konfiguracyjnym menedżera paczek w dwóch prywatnych repozytoriach. Pliki konfiguracyjne, które nie wyglądają jak kod, prześlizgują się przez review, bo nikt nie czyta ich jak kodu. Wzorzec, na który przeszedłem, trzyma w repozytorium wyłącznie mapowanie rejestru, a token tylko w configu na poziomie użytkownika.

Sama dyscyplina nie wystarczyłaby, więc dodałem skanowanie sekretów: automatyczne sprawdzanie wychodzących commitów pod kątem ciągów, które wyglądają jak dane uwierzytelniające. Globalny hook pre-push uruchamia gitleaks na wszystkim, co ma opuścić maszynę. Jest ustawiony przez globalną ścieżkę hooków, więc obejmuje każde repozytorium, także te sklonowane później. W przeciwieństwie do hooka komend ten blokuje przy błędzie: jeśli brakuje skanera, push jest zablokowany. Fałszywe alarmy trafiają do pliku ignorowanych wyjątków w repozytorium, gdzie widać je w review. Istnieje awaryjne pominięcie dla pojedynczego pusha, które ma pozostać rzadkością. Jedną konsekwencję musiałem zapisać: menedżery hooków per repozytorium, które ustawiają własną ścieżkę hooków, po cichu wyłączają w tym repozytorium globalny skaner, więc ich nie instaluję.

I tu jest granica. Skaner nie oflagował hasła do bazy, które trafiło do historii przez kopię zapasową configu. Skanowanie to sieć, a sieci mają dziury w kształcie wszystkiego, czego zestaw reguł nie rozpoznaje. Poprawka, która naprawdę usuwa całą klasę problemu, jest wcześniej w łańcuchu: sekrety w ogóle nie są wpisywane w komendy, więc nigdy nie trafiają do allowlisty, kopii zapasowej ani commitu.

Co powiedziałbym komuś, kto jutro robi audyt swojego harnessu

  • Zacznij od skutków, potem wypisz zapisy. Zapytaj "co mogłoby to usunąć" i wypisz formę CLI, formę API i formę z surowym tokenem. Pilnuj wszystkich, bo inaczej żadna blokada się nie liczy.
  • Zapisz, na czym opiera się każda granica. Zakres tokenu i reguły hostingu to gwarancje; lokalny hook to skrypt, który sam utrzymujesz. Wybieraj tokeny, które po prostu nie mają niebezpiecznej możliwości.
  • Wiedz, jak zawodzi każde zabezpieczenie, i testuj w obie strony. Po każdej zmianie uruchamiaj przypadki, które muszą zostać zablokowane, i takie, które muszą przejść. Zabezpieczenie przepuszczające wszystko przy błędzie, bez testu, nie różni się od braku zabezpieczenia.
  • Traktuj allowlistę jak config, który gnije. Przeglądaj ją regularnie skryptem, który pilnuje, czego nie wolno mu dotknąć.
  • Nigdy nie dawaj "always allow" komendzie z sekretem. Właśnie przez ten przycisk dane uwierzytelniające lądują w plikach konfiguracyjnych i kopiach zapasowych.
  • Skanuj przy pushu, blokuj, gdy brakuje skanera, i rotuj przy każdym wycieku. I pogódź się z tym, że skaner czegoś nie zauważy. Dlatego nawyk na wcześniejszym etapie znaczy więcej.

Pytania, na które odpowiada ten wpis

Jak zrobić audyt zabezpieczeń agenta kodującego?
Zacznij od destrukcyjnych skutków, na których ci zależy, takich jak usunięcie gałęzi albo force push, i wypisz wszystkie sposoby wywołania każdego z nich: komendę CLI, stojące za nią wywołanie API i surowe żądanie HTTP z tokenem. Potem zapisz, czy dany skutek zatrzymuje infrastruktura (zakresy tokenu, reguły hostingu), czy tylko lokalny hook, który sam utrzymujesz, i przetestuj każdą lokalną regułę.
Dlaczego sekrety trafiają do ustawień Claude Code?
Odpowiedź "always allow" na pytanie o uprawnienie zapisuje całą komendę dosłownie w allowliście, więc komenda z hasłem albo kluczem API wpisanym bezpośrednio zostawia ten sekret w pliku ustawień. Jeśli config ma kopię zapasową w repozytorium, sekret trafia też do historii gita i trzeba go zrotować. Rozwiązanie: wczytuj sekrety do zmiennej środowiskowej i nigdy nie dawaj "always allow" komendzie, która zawiera sekret.
Co znaczy, że hook bezpieczeństwa przepuszcza wszystko przy błędzie (fail open)?
Hook działa w trybie fail open, gdy wewnętrzny błąd kończy go tak, jakby komenda była dozwolona, co po cichu wyłącza ochronę. Wystarczy do tego źle umieszczona flaga regexa wpisana w wzorzec, która rzuca błąd przy kompilacji. Wyłapie to autotest z przypadkami, które muszą zostać zablokowane, i takimi, które muszą przejść, uruchamiany po każdej zmianie.