Przejdź do głównej zawartości

Ochrona wyroczni: testy, których agent nie może zmienić

Ochrona wyroczni polega na tym, że sprawdzenia decydujące o „gotowe” (testy, snapshoty, konfiguracja testów i workflowy CI) są poza zasięgiem agenta w zadaniu, które oceniają. Ochrona ma warstwy: reguły deny i sandbox w sesji, CODEOWNERS, wymagany check CI spoza diffa, scenariusze holdout poza repozytorium oraz audyt wykrywający osłabione testy.

Poprosiłeś agenta o naprawienie padającego testu koszyka. Po dwudziestu minutach suita jest zielona, podsumowanie mówi „naprawiłem błąd zaokrąglania”, a diff ma 40 linii. Cztery z nich są w teście: toBe(19.99) zmieniło się w toBeCloseTo(20, 0). Nikt nie kłamał celowo. Agent miał sprawić, żeby test przeszedł, a edycja testu była najkrótszą drogą. Ta strona zamyka tę drogę i sprawia, że zmiana testu, gdy naprawdę jest potrzebna, nie przechodzi po cichu.

  • Mapę wyroczni jako wzorce ścieżek do użycia w każdej warstwie.
  • Blokady sesji do wklejenia: reguły deny, ustawienia sandboxa i hook PreToolUse dla Claude Code; profil uprawnień Codex przetestowany na Codex CLI 0.157.1; oraz to, co Cursor potrafi, a czego nie potrafi wymusić.
  • Blokady w forge i w CI: blok CODEOWNERS, wymagany workflow, którego pull request nie może edytować, i job uruchamiający testy z gałęzi bazowej na nowym kodzie.
  • Job holdout, który uruchamia scenariusze akceptacyjne nigdy niewidziane przez agenta i raportuje tylko identyfikatory i liczby.
  • Skrypt audytu osłabień, przetestowany na realnie osłabionych zmianach.
  • Cztery prompty do skopiowania i ćwiczenie red-team, które po każdej aktualizacji narzędzi dowodzi, że blokada trzyma.

„Spraw, żeby testy przeszły” jest spełnione tak samo przez poprawkę kodu, jak przez zmianę testu, a test bywa mniejszą edycją. Polecenie „nigdy nie zmieniaj testów” w CLAUDE.md albo AGENTS.md to tekst, który model waży wobec zadania, a nie reguła, którą coś egzekwuje. Jak ujmuje to rejestr autonomii na poziomie 5: jeśli agent może edytować wyrocznię, wyrocznia jest tylko sugestią. Ta strona zakłada, że pętla informacji zwrotnej już istnieje (etap testowania), a jej siłę zostawia stronie jak silna jest twoja wyrocznia; tutaj chodzi tylko o to, żeby trzymać ją poza zasięgiem agenta.

Wyrocznia to wszystko, czego zmiana może zamienić czerwony przebieg w zielony bez poprawy kodu. To więcej niż katalog tests/:

Składnik wyroczniPrzykładyJak się ją osłabia
Kod testówtests/**, **/*.test.ts, **/*_test.go, test_*.pyPoluzowana asercja, usunięty przypadek, .skip, xfail
Zapisane oczekiwania**/__snapshots__/**, pliki golden, fixturesSnapshot wygenerowany ponownie z -u, fixture dopasowany do błędu
Konfiguracja testów i pokryciavitest.config.ts, jest.config.*, pytest.ini, .coveragercPliki wyłączone z przebiegu, obniżony próg pokrycia
Bramki statycznetsconfig.json, konfiguracja lintera, // @ts-ignore, # noqaWyłączony strict, wyłączona reguła, dopisany komentarz tłumiący
Pipeline.github/workflows/**Usunięty krok testów albo continue-on-error
Same blokadyCODEOWNERS, .claude/, .codex/Reguła ochrony usunięta razem ze zmianą

Komentarzy tłumiących w plikach produkcyjnych nie da się zablokować ścieżką; wyłapuje je audyt osłabień opisany dalej.

Żadna pojedyncza kontrola nie obejmuje każdego agenta i każdego obejścia, więc układasz pięć warstw. Każda łapie to, co przepuściła poprzednia.

WarstwaGdzie żyjeCo zatrzymujeCo przez nią przechodziKtórych agentów wiąże
1. Blokada sesjiUstawienia agenta na maszynie, na której działaEdycję w chwili próby, z komunikatem, na który agent może zareagowaćWszystko, czego ustawienia nie obejmują; agentów w chmurze z innymi ustawieniamiJedno narzędzie, jedna maszyna
2. Blokada w forgeCODEOWNERS plus reguła „Require review from Code Owners”Merge zmiany wyroczni bez akceptacji wskazanego właścicielaNic, jeśli reguła jest włączona, a plik poprawnyKażdego agenta i każdego człowieka
3. CI poza diffemWymagany workflow w innym repozytoriumPull request, który przepisuje własny pipelineSłabe testy: CI uruchamia to, co istniejeKażdy pull request
4. HoldoutyOsobne repozytorium, którego agent nie może czytaćKod dostrojony do widocznych testówZachowanie, którego nie opisuje żaden scenariuszKażdy pull request
5. Audyt osłabieńJob CI nad diffemZmiany testów, które wyglądają na uzasadnione, a luzują sprawdzenieSubtelne osłabienie semantyczne; to czyta właściciel koduKażdy pull request

Warstwa 1 daje najszybszą informację zwrotną. Warstwy 2–5 trzymają wtedy, gdy agent działa w chmurze, w terminalu kogoś innego albo w narzędziu, którego nie skonfigurowałeś.

  1. Zmapuj wyrocznię. Uruchom w repozytorium pierwszy prompt poniżej. Zwraca wyrocznię jako listę ścieżek i wzorców. Przejrzyj ją raz; tej samej listy używasz w każdej warstwie.

  2. Zablokuj ją w sesji. Zastosuj ustawienia swojego narzędzia z zakładek w następnej sekcji. Zacommituj je, żeby każdy programista i każde uruchomienie agenta w CI dziedziczyło blokadę.

  3. Nadaj wyroczni właścicieli. Dodaj blok CODEOWNERS i włącz „Require review from Code Owners” dla gałęzi domyślnej.

  4. Wynieś decydujący check poza diff. Umieść joby testów, wyroczni bazowej i audytu w workflowie, którego pull request nie może edytować, i wymuś go regułą organizacji (ruleset).

  5. Dodaj holdouty. Załóż prywatne repozytorium scenariuszy i job holdout. Zacznij od 10–20 scenariuszy dla przepływów, na których zarabiasz.

  6. Rozdziel zmiany testów od zmian kodu. Gdy zachowanie musi się zmienić, sesja pisząca testy tworzy nowe testy, właściciel kodu je zatwierdza, a osobna sesja implementacyjna sprawia, że przechodzą, przy zablokowanych testach.

  7. Zaatakuj własną blokadę. Wykonaj ćwiczenie red-team z końca tej strony i powtarzaj je po każdej aktualizacji agenta albo CI.

Jak zablokować pliki testowe w Claude Code, Codex i Cursorze?

Dział zatytułowany „Jak zablokować pliki testowe w Claude Code, Codex i Cursorze?”

Każde z trzech narzędzi egzekwuje blokadę sesji inaczej, a jednego nie da się dziś w pełni zweryfikować. Użyj zakładki dla każdego narzędzia, z którego korzysta zespół.

W .claude/settings.json użyj trzech mechanizmów: reguł deny dla wbudowanych narzędzi plikowych, sandboxa dla wszystkiego, co zapisuje polecenie powłoki, oraz hooka PreToolUse, który mówi agentowi, co zrobić zamiast edycji. Sprawdzone 26 września 2026 z Claude Code 2.1.283 i dokumentacją uprawnień, sandboxa i hooków.

{
"permissions": {
"deny": [
"Edit(tests/**)",
"Edit(**/*.test.ts)",
"Edit(**/__snapshots__/**)",
"Edit(/vitest.config.ts)",
"Edit(/.github/**)",
"Edit(/CODEOWNERS)"
]
},
"sandbox": {
"enabled": true,
"allowUnsandboxedCommands": false,
"filesystem": {
"denyWrite": ["./tests", "./.github", "./CODEOWNERS", "./vitest.config.ts"]
}
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-oracle.sh" }
]
}
]
}
}

Dlaczego wszystkie trzy:

  • Reguły deny blokują w każdym trybie uprawnień, także w bypassPermissions, a reguła allow nie wytnie z nich wyjątku. Reguły Edit(...) obejmują też Write; reguła ścieżki zapisana jako Write(...) jest akceptowana, ale nigdy nie jest sprawdzana. Jako reguła deny Edit(tests/**) pasuje do katalogu tests na dowolnej głębokości, a wiodący / w Edit(/vitest.config.ts) kotwiczy ścieżkę w katalogu głównym projektu. Reguły deny dla Edit działają też na polecenia powłoki, które Claude Code rozpoznaje, takie jak sed, tee i przekierowania >.
  • Sandbox zamyka lukę, którą reguły deny zostawiają: według dokumentacji nie obejmują one „arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself”. sandbox.filesystem.denyWrite egzekwuje system operacyjny dla każdego polecenia powłoki i jego procesów potomnych. Uwaga na inną składnię ścieżek: tu ./tests jest względne wobec projektu, w regule uprawnień tę rolę pełni /tests. Sandbox działa na macOS, Linuksie i WSL2, nie na natywnym Windowsie. allowUnsandboxedCommands: false nie pozwala ponowić zablokowanego polecenia poza sandboxem.
  • Hook zamienia gołą odmowę we wskazówkę. Agent czyta stderr blokady z kodem wyjścia 2 i zmienia kierunek:
#!/usr/bin/env bash
# .claude/hooks/protect-oracle.sh: refuse edits to the files that decide "done"
command -v jq >/dev/null || { echo "protect-oracle: jq missing" >&2; exit 2; }
file=$(jq -r '.tool_input.file_path // .tool_input.notebook_path // empty')
rel=${file#"$CLAUDE_PROJECT_DIR"/}
case "$rel" in
tests/*|*/tests/*|*.test.ts|*.spec.ts|*/__snapshots__/*|.github/*|CODEOWNERS|vitest.config.*|.claude/*)
echo "BLOCKED: $rel is part of the oracle for this task. Change the code under test instead." \
"If you believe the test itself is wrong, stop and write your reasoning to TEST_DISPUTE.md." >&2
exit 2 ;;
esac
exit 0

Skrypt wymaga jq w PATH; bez niego linia kontrolna blokuje każdą edycję komunikatem „jq missing”, zamiast po cichu przepuszczać wszystkie (reguły deny i tak blokują). Wzorzec */tests/* odpowiada dopasowaniu reguły deny na dowolnej głębokości, więc w monorepo packages/api/tests/x.ts dostaje ten sam komunikat. Nadaj skryptowi prawo wykonywania (chmod +x .claude/hooks/protect-oracle.sh). Blokuje kod wyjścia 2; kod 1 to błąd nieblokujący i edycja przechodzi. Katalog .claude/ jest też ścieżką chronioną: w trybie Manual, acceptEdits i w trybie auto zapis do niego wymaga twojej zgody albo trafia do klasyfikatora zamiast automatycznej akceptacji, więc agent nie usunie po cichu własnej blokady.

Blokada w forge to jedyna warstwa, która wiąże każdą powierzchnię agenta: lokalne CLI, agenta w chmurze, agenta uruchamianego z GitHuba i człowieka. Zmianę wyroczni da się zmergować tylko za zgodą wskazanego zespołu.

# .github/CODEOWNERS: changes to the oracle need a named owner's approval
/tests/ @acme/test-owners
*.test.ts @acme/test-owners
**/__snapshots__/ @acme/test-owners
/vitest.config.ts @acme/test-owners
/.github/ @acme/platform
/.claude/ @acme/platform
/.codex/ @acme/platform

Następnie włącz Require review from Code Owners w regule ochrony gałęzi albo w rulesecie dla gałęzi domyślnej. O tym, czy to działa, decydują cztery zachowania opisane w dokumentacji GitHuba:

  • Obowiązuje CODEOWNERS z gałęzi bazowej. Pull request, który edytuje CODEOWNERS, nie zmieni recenzentów samego siebie, ale może osłabić plik dla wszystkich kolejnych. Linia /.github/ sprawia, że zmiana CODEOWNERS też wymaga akceptacji zespołu platformy.
  • Niepoprawne linie są po cichu pomijane. Literówka zostawia ścieżkę bez właściciela. Po każdej edycji sprawdź widok błędów pliku na GitHubie albo endpoint REST zwracający błędy CODEOWNERS.
  • Wystarczy jeden właściciel. Jeśli ścieżka ma kilku właścicieli, reguła jest spełniona po jednej akceptacji. Ogranicz właścicieli wyroczni do zespołu, który naprawdę czyta testy.
  • Właściciele muszą mieć uprawnienia zapisu do repozytorium, żeby dostać prośbę o review.

Jeśli CODEOWNERS nikogo nie poprosił o review, przyczyną jest jedno z powyższych: pominięta linia, właściciel bez prawa zapisu albo gałąź docelowa z innym CODEOWNERS. Sprawdź konfigurację jednorazowym pull requestem, który dotyka tests/. Ten sam wzorzec, CODEOWNERS plus regułę review właścicieli kodu, GitHub zaleca dla plików konfiguracyjnych, które mógłby zmienić jego Copilot cloud agent.

Uruchamiaj decydujący check tam, gdzie pull request go nie zmieni

Dział zatytułowany „Uruchamiaj decydujący check tam, gdzie pull request go nie zmieni”

Workflow pull_request uruchamia się z merge commita pull requesta. Wykonuje się więc ten YAML, który zawiera pull request, i agent, który usunie krok testów z .github/workflows/ci.yml, nadal dostanie zielony check o nazwie test. CODEOWNERS na .github/ sprawia, że taka edycja jest widoczna. Wymagany workflow odbiera tę możliwość całkowicie.

W rulesecie organizacji reguła Require workflows to pass before merging wymusza plik workflowu z innego repozytorium. Pull request nie może go edytować, bo tego pliku nie ma w diffie. Tam umieść joby wyroczni:

# acme/ci-policy/.github/workflows/oracle.yml, required by an org ruleset
name: oracle
on: pull_request
permissions:
contents: read
pull-requests: read # lets oracle-approved.sh list the pull request's reviews
jobs:
base-oracle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0, persist-credentials: false }
- uses: actions/checkout@v7
with:
repository: acme/ci-policy
path: .policy
token: ${{ secrets.POLICY_READ_TOKEN }}
persist-credentials: false
- id: owner
name: Check for an oracle owner's approval of this exact commit, before any pull request code runs
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: bash .policy/oracle-approved.sh >> "$GITHUB_OUTPUT"
- name: Run the base branch's tests and runner config against this pull request's code
env:
OWNER_APPROVED: ${{ steps.owner.outputs.approved }}
run: |
git checkout ${{ github.event.pull_request.base.sha }} -- tests/ vitest.config.ts
npm ci
npx vitest run tests/ || {
[ "$OWNER_APPROVED" = true ] || exit 1
echo "::warning::Old expectations fail; accepted because an oracle owner approved this commit."
}
oracle-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0, persist-credentials: false }
- uses: actions/checkout@v7
with:
repository: acme/ci-policy
path: .policy
token: ${{ secrets.POLICY_READ_TOKEN }}
persist-credentials: false
- id: owner
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: bash .policy/oracle-approved.sh >> "$GITHUB_OUTPUT"
- env:
OWNER_APPROVED: ${{ steps.owner.outputs.approved }}
run: |
bash .policy/oracle-audit.sh ${{ github.event.pull_request.base.sha }} || {
[ "$OWNER_APPROVED" = true ] || exit 1
echo "::warning::Weakening signal accepted because an oracle owner approved this commit."
}

Oba joby uruchamiają kod kontrolowany przez pull request (skrypty cyklu życia npm ci, przebieg testów), więc blok permissions na poziomie workflowu ogranicza ich GITHUB_TOKEN do czytania repozytorium i jego pull requestów. Job base-oracle przywraca każdy plik testowy, który pull request zmienił albo usunął, oraz konfigurację runnera do wersji z gałęzi bazowej, zostawia testy dodane przez pull request i uruchamia całość na nowym kodzie. Porażka pokazuje dokładnie, które stare oczekiwania zmiana łamie. Każde z nich musi się znaleźć w zmianie specyfikacji (spec delta) pull requesta jako zamierzona zmiana zachowania; porażka bez odpowiadającej linii to regresja albo osłabiony test. Dopasuj tests/ i polecenie runnera do swojego układu.

Dokumentacja rulesetów GitHuba dodaje dwa ograniczenia. Reguła blokuje bezpośrednie pushe, więc stosuj ją tylko do gałęzi zmienianych przez pull requesty. Prywatne repozytorium z workflowem obsłuży tylko repozytoria prywatne, wewnętrzne tylko wewnętrzne i prywatne, a publiczne wszystkie. Jeśli repozytorium z polityką jest prywatne albo wewnętrzne, zezwól też na dostęp do jego workflowów z innych repozytoriów w Settings → Actions → General → Access tego repozytorium, bo inaczej wymagany workflow nigdy się nie uruchomi.

Uzasadniona zmiana zachowania z definicji łamie stare oczekiwania, więc oba joby potrzebują ścieżki zaliczenia, bo inaczej każda zamierzona zmiana testu byłaby zablokowana na zawsze. Tą ścieżką jest zatwierdzenie, nie etykieta: etykietę może dodać każdy z dostępem triage, także token agenta. oracle-approved.sh leży w repozytorium z polityką obok skryptu audytu i uruchamia się przed jakimkolwiek kodem pull requesta:

#!/usr/bin/env bash
# oracle-approved.sh: print approved=true only if a listed oracle owner's latest review
# approves this exact head commit. Any API or parse error fails the step (fail closed).
set -euo pipefail
: "${REPO:?}" "${PR:?}" "${HEAD_SHA:?}"
owners=$(grep -vE '^\s*(#|$)' .policy/oracle-approvers.txt | jq -R . | jq -sc .)
reviews=$(gh api --paginate "repos/$REPO/pulls/$PR/reviews" | jq -s 'add // []')
ok=$(jq -r --argjson owners "$owners" --arg sha "$HEAD_SHA" '
[ .[] | select(.state != "COMMENTED" and .state != "PENDING") ]
| group_by(.user.login) | map(max_by(.submitted_at))
| any(.[]; .state == "APPROVED" and .commit_id == $sha
and (.user.login as $u | $owners | index($u)))' <<<"$reviews")
if [ "$ok" = true ]; then echo "approved=true"; else echo "approved=false"; fi

oracle-approvers.txt zawiera po jednym loginie GitHuba w wierszu: osoby z zespołu wyroczni z CODEOWNERS. To plik w repozytorium z polityką, a nie zapytanie o zespół, bo GITHUB_TOKEN workflowu nie może czytać członkostwa w zespołach organizacji. Utrzymuj obie listy w zgodzie.

Przebieg zamierzonej zmiany: joby padają, właściciel z listy czyta zmianę specyfikacji i zatwierdza, po czym klika Re-run failed jobs. Ponowne uruchomienie używa tego samego zdarzenia i commita, ale odpytuje recenzje od nowa, więc check przechodzi z adnotacją ostrzegawczą. Trzy właściwości trzymają tę furtkę zamkniętą. Zatwierdzenie musi dotyczyć dokładnie commita HEAD, więc push po zatwierdzeniu ponownie uzbraja check. Późniejsze „request changes” właściciela albo odrzucenie (dismiss) recenzji anuluje zatwierdzenie, a zwykły komentarz nie. Błąd API, brak pliku z listą albo puste wyjście kończą job porażką zamiast zaliczenia. Uruchomiliśmy skrypt na spreparowanych listach recenzji: zatwierdzenie commita HEAD przez właściciela dało approved=true; zatwierdzenie przez osobę spoza listy, zatwierdzenie starszego commita, zatwierdzenie z późniejszym „request changes”, odrzucone zatwierdzenie i brak recenzji dały approved=false; błąd API zakończył skrypt kodem niezerowym. Nie wyłączaj przy tym „Require review from Code Owners”: skrypt rozstrzyga, czy check przechodzi, a reguła gałęzi blokuje merge bez właściciela.

Nawet zablokowana suita uczy agenta, jak wygląda „gotowe”, bo agent może czytać testy. Po wielu iteracjach kod dryfuje w stronę przechodzenia właśnie tych przypadków. Zbiór holdout to odpowiedź z uczenia maszynowego na ten sam problem: scenariusze, których agent nigdy nie widział, uruchamiane tylko w CI i raportowane tylko jako zaliczone albo nie.

# Two jobs in the required oracle.yml (same read-only top-level permissions)
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { persist-credentials: false }
- run: npm ci && npm run build
- uses: actions/upload-artifact@v7
with: { name: app-build, path: dist/ }
holdout:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with: { name: app-build, path: dist/ }
- uses: actions/checkout@v7
with:
repository: acme/checkout-holdouts
path: .holdout
token: ${{ secrets.HOLDOUT_READ_TOKEN }}
persist-credentials: false
- name: Run holdouts and print IDs and counts only
run: |
npm ci --prefix .holdout
.holdout/node_modules/.bin/playwright install --with-deps chromium
.holdout/node_modules/.bin/playwright test --config .holdout/playwright.config.ts --reporter=json > "$RUNNER_TEMP/holdout.json" || true
node .holdout/summarize.mjs "$RUNNER_TEMP/holdout.json"

Zasady, dzięki którym holdout pozostaje holdoutem:

  • Agent nie ma dostępu do odczytu repozytorium scenariuszy. Ani na maszynie programisty, ani przez serwer MCP, ani w prompcie. Token to sekret CI z prawem odczytu tylko tego jednego repozytorium.
  • Artefakt buildu musi być samowystarczalny. Job holdout przywraca tylko dist/, co działa dla aplikacji statycznej albo w pełni zbundlowanej. Jeśli serwer potrzebuje zależności w czasie działania, niech job build wysyła też produkcyjne node_modules albo kieruj scenariusze na wdrożony adres podglądu.
  • Build w jednym jobie, ocena w drugim. npm ci i build pull requesta działają w jobie build, który nigdy nie widzi scenariuszy. Job holdout nie ma checkoutu pull requesta ani jego node_modules: Playwright pochodzi z lockfile’a repozytorium holdoutów, więc zależność dodana przez pull request nie odczyta .holdout/ i nie wypisze go do logu. persist-credentials: false trzyma token poza konfiguracją git checkoutu.
  • Nazwij ryzyko, które zostaje. Testowana aplikacja to nadal kod z pull requesta, a jej serwer działa na tym samym runnerze co .holdout/. Przy ważnym zbiorze uruchamiaj aplikację w kontenerze, który nie montuje .holdout/, albo kieruj scenariusze na wdrożony adres podglądu.
  • Logi zawierają identyfikatory, nie treść. summarize.mjs wypisuje coś w rodzaju holdout: 41 passed, 2 failed (HX-07, HX-19) i kończy się kodem 1 przy każdej porażce. Kończy się kodem 1 także wtedy, gdy raportu JSON brakuje albo nie ma w nim żadnych wyników, więc awaria Playwrighta (ukryta przez || true) oblewa job, zamiast go zaliczyć. Jeśli agent przeczyta potem log CI, żeby poprawić swój pull request, dowie się, który scenariusz padł, a nie co sprawdza.
  • Oblany holdout trafia do człowieka. Właściciel przekłada go na zmianę specyfikacji albo kryterium akceptacji, słowami opisującymi zachowanie, a nie scenariusz. Gdy szczegóły scenariusza trafią do agenta, przenieś go do widocznej suity i napisz nowy w jego miejsce.
  • Sekrety trafiają tylko do gałęzi z tego samego repozytorium. GitHub nie przekazuje sekretów do uruchomień pull_request z forków, więc job holdout nie działa dla pull requestów z forków.

Scenariusze napisane na podstawie wykonywalnych kryteriów akceptacji są dobrymi holdoutami. Sprawdzenia niezmienników z testów opartych na właściwościach jeszcze trudniej przeuczyć, bo testują regułę, a nie przykład.

Wykrywaj osłabianie testów, gdy testy zmieniają się zasadnie

Dział zatytułowany „Wykrywaj osłabianie testów, gdy testy zmieniają się zasadnie”

Testy muszą się zmieniać, gdy zmienia się zachowanie, więc warstwy 1 i 2 nie mogą znaczyć „testy nigdy się nie zmieniają”. Znaczą: „testy nigdy nie zmieniają się po cichu”. Skrypt audytu flaguje mechaniczne formy osłabienia i oblewa job, żeby właściciel kodu musiał to obejrzeć:

#!/usr/bin/env bash
# oracle-audit.sh BASE_SHA: flag changes that can make a check pass without fixing the code
set -euo pipefail
base="$1"
oracle='(^|/)(tests?|__snapshots__)/|\.(test|spec)\.[jt]sx?$|_test\.(py|go)$|^\.github/|^CODEOWNERS$|(vitest|jest|playwright)\.config\.|^\.coveragerc$|^pytest\.ini$'
changed=$(git diff --name-only "$base"...HEAD | grep -E "$oracle" || true)
[ -z "$changed" ] && { echo "Oracle untouched."; exit 0; }
echo "Oracle files changed:"; echo "$changed" | sed 's/^/ /'
diff=$(git diff -U0 "$base"...HEAD -- $changed)
skips=$(grep -cE '^\+.*(\.skip\(|\.only\(|\bxit\(|\bxdescribe\(|@pytest\.mark\.(skip|xfail)|t\.Skip\()' <<<"$diff" || true)
removed=$(grep -cE '^-.*\b(expect|assert)' <<<"$diff" || true)
added=$(grep -cE '^\+.*\b(expect|assert)' <<<"$diff" || true)
snaps=$(grep -cE '__snapshots__/|\.snap$' <<<"$changed" || true)
configs=$(grep -cE '(vitest|jest|playwright)\.config\.|^\.coveragerc$|^pytest\.ini$' <<<"$changed" || true)
echo "Added skip/only/xfail markers: $skips"
echo "Assertion lines removed: $removed, added: $added"
echo "Snapshot files changed: $snaps"
echo "Runner or coverage config files changed: $configs"
if [ "$skips" -gt 0 ] || [ "$removed" -gt "$added" ] || [ "$snaps" -gt 0 ] || [ "$configs" -gt 0 ]; then
echo "WEAKENING SIGNAL: a code owner must approve this oracle change." >&2
exit 1
fi

Uruchomiliśmy go na pliku testowym, w którym dwie dokładne asercje zamieniono na jedno toBeTruthy() wewnątrz test.skip. Zgłosił jeden znacznik skip, dwie usunięte linie asercji i jedną dodaną, i zakończył się kodem 1. Zmiana, która tylko dopisywała wzorzec exclude do vitest.config.ts, dała jeden zmieniony plik konfiguracji i kod 1. Pull request, który tylko dodawał nowy test, przeszedł. Skrypt liczy linie, nie rozumie ich. Zamiana toBe(19.99) na toBeCloseTo(20, 0) nie zmienia bilansu, dlatego każda zmiana wyroczni trafia też do właściciela kodu, a prompt audytowy poniżej pyta o semantykę. Rozszerz wzorce o komentarze tłumiące ze swojego stosu (@ts-ignore, # noqa, eslint-disable) i klucze progów.

Najczystsza obrona to nigdy nie pozwalać jednej sesji pisać jednocześnie testów i kodu. To programowanie sterowane testami z jawnie określonym pochodzeniem testów:

  1. Sesja pisząca testy. Agent pisze albo zmienia testy na podstawie kryteriów akceptacji, przy zablokowanym kodzie produkcyjnym (w Claude Code Edit(src/**) w deny). Właściciel kodu zatwierdza testy. Czytanie testów to czytanie specyfikacji, znacznie krótszej niż implementacja.
  2. Sesja implementacyjna. Nowa sesja sprawia, że zatwierdzone testy przechodzą, przy zablokowanej wyroczni. Jeśli uzna, że test jest błędny, zatrzymuje się i pisze TEST_DISPUTE.md, a decyzję podejmuje człowiek.

Udowodnij działanie blokady ćwiczeniem red-team na gałęzi roboczej i zachowaj wynik jako dowód.

Porównaj tabelę z warstwami. Próby 1–4 powinny zatrzymać się w sesji w Claude Code i Codex. Próba 5 powinna zatrzymać się w sesji, jeśli konfiguracja runnera jest zablokowana; jeśli przejdzie, audyt padnie na zmianie konfiguracji, a base-oracle uruchomi się z konfiguracją z gałęzi bazowej. Próba 6 powinna zatrzymać się w sesji, a nawet gdyby przeszła, nie miałaby znaczenia, bo wymagany workflow leży gdzie indziej. Mimo to wypchnij gałąź i potwierdź, że CODEOWNERS prosi właścicieli o review, a job audytu pada. Każda próba, która przeszła, to poprawka w warstwie, która powinna była ją zatrzymać.

Zatwierdzanie należy do dwóch ról. Właściciele kodu dla ścieżek wyroczni zatwierdzają każdą zmianę wyroczni i nikt inny nie może tego zrobić. Tech lead odpowiada za konfigurację blokady i ćwiczenie: uruchom je przy wdrożeniu blokady, po każdej aktualizacji CLI agenta albo runnerów CI i przy zmianie układu repozytorium. Tech leadom, którzy śledzą to w całym zespole, wystarczy jedna liczba tygodniowo: odsetek pull requestów agentów modyfikujących pliki wyroczni i ile z nich oflagował audyt. Rosnący odsetek oznacza, że zmiany testów i kodu znowu się mieszają.

git checkout albo git merge pada z „unable to unlink old”. Sandbox Claude Code nie pozwala zastąpić pliku pod ścieżką z denyWrite, a przełączenie gałęzi dokładnie tego wymaga. Naprawa: przełączaj gałęzie poza sesją agenta albo daj każdemu zadaniu osobny worktree (zobacz stronę o równoległych agentach w izolowanych worktree), żeby agent nigdy nie przełączał gałęzi.

Agent utknął, bo test naprawdę jest błędny. Blokada bez wyjścia sprawia, że agent miota się, aż skończy mu się budżet. Naprawa: polecenie TEST_DISPUTE.md w komunikacie hooka i w prompcie implementacyjnym daje mu legalny sposób, żeby się zatrzymać. Człowiek czyta spór, a jeśli test ma się zmienić, zmienia się w sesji piszącej testy.

Treść holdoutu wyciekła do kontekstu agenta. Ktoś wkleił oblany scenariusz do promptu albo log wypisał treść asercji. Naprawa: przenieś ten scenariusz do widocznej suity, napisz nowy i popraw summarize.mjs, żeby wypisywał tylko identyfikatory.

Wyrocznia nigdy nie była dość silna, żeby ją chronić. Zablokowana suita, która nie łapie błędów, to teatr bezpieczeństwa. Naprawa: zmierz ją testami mutacyjnymi, jak opisuje strona jak silna jest twoja wyrocznia, zanim zaufasz zielonemu wynikowi z pętli bez nadzoru.

Instrukcje i blokada mówią co innego. CLAUDE.md albo AGENTS.md każe agentowi „aktualizować testy w razie potrzeby”, a blokada za każdym razem to odrzuca. Naprawa: zastąp tę linię opisem dwufazowego protokołu, żeby instrukcje i egzekwowanie mówiły to samo.