Zielony test niczego nie dowodzi: 500 notatek mojego agenta AI

Mój agent AI zapisuje notatkę po każdym błędzie. Po ponad 500 notatkach największa lekcja dotyczy testów: pięć prawdziwych przypadków, w których zielona suita ukryła błąd.

Zeszyt błędów

Większość kodu w Cmssy pisze Claude Code, a ja go recenzuję. To działa dzięki jednemu nawykowi: za każdym razem, gdy coś pójdzie źle, agent zapisuje notatkę. Co się stało, dlaczego i jak zastosować tę lekcję następnym razem.

Notatki leżą w moim vaulcie Obsidiana, a agent czyta indeks danego obszaru, zanim go dotknie. Jest ich już ponad 500.

Duża część dotyczy testów. Z różnych stron dochodzą do tego samego zdania: zielony test niczego nie dowodzi, dopóki nie zobaczysz, jak pada. Poniżej pięć przypadków z tego zeszytu, z datami. Dwa z nich były dziurami bezpieczeństwa w moim własnym produkcie. Obie są naprawione.

Jak wygląda notatka

Każda notatka ma ten sam układ: jednozdaniowy opis, konkretny incydent, przyczyna i sposób stosowania reguły. Ta jest skrócona.

guard-must-be-proven-to-refuse.mdmarkdown
---
name: guard-must-be-proven-to-refuse
description: Test, którym sam ustawiam nagłówek i patrzę na 200, dowodzi że strażnik WPUSZCZA - nigdy że ODMAWIA
type: feedback
---

**Why:** test wpuszczenia jest zielony także wtedy, gdy strażnika nie ma wcale.
Dowód istnienia strażnika to wyłącznie żądanie, które ma dostać odmowę - i ją dostaje.

**How to apply:**
- Każdy strażnik = para testów: uprawnione żądanie przechodzi, nieuprawnione dostaje 401/403.
- Nazwij w teście dokładnie podrobione żądanie.
- Zanim napiszesz w PR "nie wystawia tego na internet", wyślij to żądanie na żywy deploy.

1. Strażnik, który szukał napisu

29 lipca. Agent napisał dwa strażniki źródłowe wokół mappera pól. Oba były zielone. Oba były bezwartościowe.

Pierwszy sprawdzał, czy plik zawiera nazwę toPropertyField. W uproszczeniu:

mapper.guard.test.tstypescript
it("uses the shared field mapper", () => {
  const source = readFileSync(mapperPath, "utf8");
  expect(source).toContain("toPropertyField");
});

Przechodził przy w pełni przywróconym, zepsutym mapperze, bo toPropertyFields zawiera tę nazwę jako podciąg. Drugi strażnik wykrywał tłumaczenie płaskie na zagnieżdżone, a prawdziwym błędem było pomijanie kluczy. Agent recenzujący udowodnił to, pisząc szósty mapper, który miał ten błąd i dalej przechodził.

Notatka zapisana potem mówi, że test, który nie potrafi zaświecić się na czerwono, jest dekoracją i zwiększa ryzyko, bo wygląda jak pokrycie. Sprawdzaj właściwość („nic, co wywołujący może ustawić, nie ginie”), a nie obecność symbolu. A gdy błąd polega na braku czegoś, skan źródeł z definicji go nie zobaczy. Wtedy potrzebny jest niezmiennik, na przykład round-trip.


2. Test, który bronił podatności

4 października. Testy logowania w panelu admina zawierały to:

auth.test.tstypescript
it("never calls the backend - the token is already proof", async () => {
  await authorize("oauth-token", { accessToken: accessToken() });
  expect(authFetch).not.toHaveBeenCalled();
});

To zdanie było treścią podatności. Callback logowania jest publicznym endpointem, a authorize tylko dekodowało środkowy człon JWT. Bez sprawdzenia podpisu, bez sprawdzenia wystawcy. Każdy mógł złożyć token ręcznie i dostać ważną sesję admina.

Suita była zielona z jednego powodu: test wymagał, żeby backend nie był pytany. Token jest dowodem tylko dla kogoś, kto wie, kto go wybił. Publiczny endpoint wierzy wołającemu na słowo.

Poprawka weszła 7 października. Został sposób czytania testów w kodzie logowania i tenantów: pytanie brzmi „co to zdanie twierdzi o świecie”, a nie „czy przechodzi”. Test, który sprawdza, że wywołanie weryfikujące nie nastąpiło, to sygnał ostrzegawczy. Taki test się kasuje i nazywa w opisie pull requesta, a nie łagodzi. Złagodzony zostawia tezę w repo.


3. Strażnik, o którym wiadomo było tylko, że wpuszcza

10 września. W SDK 16.7.0 wyszła trasa z danymi bloków, a opis pull requesta mówił, że „odpowiada tylko zweryfikowanemu żądaniu edytora”. Nie odpowiadała tylko jemu. Strażnik czytał nagłówek x-cmssy-edit. Proxy nadpisuje ten nagłówek tylko na trasach objętych matcherem, a standardowy matcher wycina /api/*. Na trasie API był to po prostu nagłówek od wołającego.

Przeszło, bo test lokalny ustawiał nagłówek ręcznie, dostawał 200 i zapisywał sukces. To mierzy, że mechanizm wpuszcza żądanie. Nikt nie zapytał, czy jakieś odrzuca.

SprawdzenieCzego dowodzi
Żądanie z nagłówkiem zwraca 200Trasa wpuszcza żądania. Prawda także wtedy, gdy strażnika nie ma
GET zwraca 405Trasa jest zamontowana
POST z podrobionym nagłówkiem zwraca 403Strażnik odmawia

Każdy strażnik to teraz para testów: uprawnione żądanie przechodzi, nieuprawnione dostaje 401 albo 403. Sam pierwszy test jest wart zero. Notatka dodaje też regułę projektową: sygnał autoryzacji wybijaj po zweryfikowanej stronie i nieś go w żądaniu. Nagłówek ustawiany przez middleware jest wiarygodny tylko na trasach, które to middleware faktycznie obsługuje, więc sprawdź matcher, a nie założenie.


4. Rada z review, która osłabiła test

Recenzent zaproponował rozsądnie brzmiącą zmianę: nie sprawdzaj komunikatu błędu graphql-js, bo wersja minor go przepisze. Sprawdź, że data jest puste i że jest jeden błąd.

Po zastosowaniu rady mutant t.int → t.float przeżył. Przy Float ułamek dociera do strażnika w serwisie, który też zwraca jeden błąd i puste data. Asercja przestała rozróżniać dwie warstwy.

Rada z review dotyczy zwykle kruchości, a nie siły. Kruchy komunikat bywa jedynym świadkiem, który rozróżnia dwie warstwy padające tak samo. Dlatego każda poprawka po review przechodzi ten sam próg co pierwotna asercja: po zmianie odpal mutanta jeszcze raz. Jeśli przeżył, rada była błędna.


5. Agent, który padł między mutacją a przywróceniem

6 października, pull request #324 w cmssy-sdk. Dwóch agentów recenzujących padło na HTTP 529 Overloaded. Jeden z nich właśnie zaczynał eksperymenty mutacyjne. Padł po zmianie kodu, a przed jego przywróceniem, więc zmutowany typ (formId: string zamieniony na formId?: string | null) został w worktree. Raportu nie było, bo agent nie wrócił.

Bez sprawdzenia kolejny agent recenzowałby zmutowany kod. Wyszły z tego trzy reguły. git status --short po każdym agencie, także po tym, który padł albo przekroczył czas. Agenty mutujące działają pojedynczo. Commit przed wpuszczeniem agenta, żeby przywrócenie było dokładne.


Reguła: zmutuj na czerwono

Wszystkie pięć przypadków kończy się tą samą procedurą. Zanim test zostanie uznany za zabezpieczenie:

  1. Zepsuj dokładnie tę właściwość, której test ma bronić.
  2. Uruchom test i zobacz czerwone.
  3. Przywróć kod.
  4. Podaj wynik mutacji, a nie samo „zielone”.

W samej procedurze siedzą dwie pułapki.

Mutacja mogła w ogóle nie trafić. 26 września mutacja regexem wycięła "boolean" z pliku, w którym ten token występuje trzy razy. Trafiła w inną tablicę niż zamierzona, testy zostały zielone i wyglądało to na mutanta, który przeżył, czyli na lukę w testach. To była mutacja, która nie dotknęła celu. Po mutacji czytaj wartość, a nie plik.

Raport subagenta to hipoteza. W jednej rundzie review wróciło dwanaście znalezisk. Pięć było prawdziwymi błędami, a trzy były błędne, przy czym te błędne były konkretne i wiarygodne. „Naprawienie” ich zepsułoby działający kod. W innym przypadku dziesięciu agentów zgodziło się jednomyślnie i nie miało racji. Każde znalezisko jest sprawdzane w kodzie, zanim trafi do planu albo pull requesta.

Co to zmieniło w repo

Notatki zamieniły się w mechanizmy. Hook odrzuca asercje, które nie mogą nie przejść. Testy-strażnicy mają w dokumentacji jedną zasadę domu: nowy strażnik jest udowadniany mutacją, a zielony przy pierwszym uruchomieniu niczego nie dowodzi. Ślepa recenzja osobnego agenta idzie przed otwarciem pull requesta, a nie po.

Agent AI nie robi mniej błędów niż ja. Robi je szybciej. Zeszyt jest sposobem na to, żeby ten sam błąd nie zdarzył się dwa razy, a testowi można ufać dopiero wtedy, gdy zobaczyłem, jak pada.