Przejdź do głównej zawartości

Zarządzanie wspólnymi hookami: kontrole runtime po review

Zarządzanie wspólnymi hookami oznacza traktowanie każdego hooka agenta, który organizacja rozdaje zespołom, jak kodu produkcyjnego: jeden wąski cel, spisany kontrakt, źródło po review przypięte do wersji, przypadki testowe (fixtures) dowodzące zachowania przy przepuszczeniu, blokadzie i awarii, dystrybucja zarządzana, wdrożenie etapowe, logi audytu i przećwiczony rollback. Sama dystrybucja nie jest dojrzałością, bo automatycznie instalowany hook rozniesie defekt równie szybko jak kontrolę.

Ta strona jest dla tech leada lub CTO, który odpowiada za pytanie 18 w CTO Scorecard, i dla inżyniera platformy, który te hooki dostarcza. Sytuacja, której ma zapobiec: zespół platformowy wypchnął na każdy laptop hook chroniący sekrety, połowa zespołu nie miała jq, hook wywracał się przy każdym wywołaniu, agent traktował to jako błąd nieblokujący, a „egzekwowana” kontrola przez trzy tygodnie niczego nie egzekwowała. Nikt nie zauważył, bo hook, który się wywraca, wygląda dokładnie jak hook, który milczy.

Q18 · Przygotowanie organizacji: Jak zarządzacie wspólnymi hookami agentów i ich adapterami per narzędzie?

Odpowiedź na maksymalny wynik: wersjonowane hooki po review, z minimalnymi uprawnieniami, przypadkami testowymi, wdrożeniem etapowym, logami audytu i przetestowanym rollbackiem.

  • Szablon kontraktu hooka, który recenzent zatwierdza, zanim powstanie jakikolwiek kod
  • Szkielet hooka typu fail-closed, który działa bez zmian w Claude Code i w Codeksie
  • Skrypt uruchamiający przypadki testowe, który w CI dowodzi zachowania przy przepuszczeniu, blokadzie i awarii, bez czytania skryptu
  • Dystrybucję zarządzaną w każdym narzędziu, dzięki której uruchamia się tylko przypięta wersja
  • Etapy wdrożenia, cztery metryki z właścicielami i ćwiczenie rollbacku do przeprowadzenia w tym kwartale
  • Trzy prompty do skopiowania: napisz kontrakt, zaatakuj implementację i wygeneruj przypadki testowe

Perspektywę dewelopera, czyli co w ogóle wkładać do hooka, opisuje strona hooki jako deterministyczne zabezpieczenia (guardrails). Ta strona dotyczy wyłącznie warstwy organizacyjnej: kto dostarcza hooki, jak się ich działanie udowadnia i jak się je wycofuje.

Dlaczego wspólne hooki wymagają ostrzejszego nadzoru niż reguły czy skills?

Dział zatytułowany „Dlaczego wspólne hooki wymagają ostrzejszego nadzoru niż reguły czy skills?”

Reguła albo skill to rada, którą czyta model. Hook to program, który uruchamia się z uprawnieniami dewelopera w stałym punkcie pętli agenta, niezależnie od tego, co zdecyduje model. Wspólny hook staje się więc częścią granicy wykonania: hook z defektem blokuje pracę wszystkim naraz, a przejęty hook uruchamia się na każdej maszynie, która go zainstalowała.

Drugi powód jest mniej oczywisty. W obu CLI większość sposobów, w jakie hook może zawieść, przepuszcza akcję. Tabela pokazuje zachowanie obu narzędzi sprawdzone 2026-09-26.

ZachowanieClaude Code 2.1.283 (kanał latest, sprawdzone 2026-09-26)Codex 0.157.1
Co blokuje wywołanie PreToolUseKod wyjścia 2 (stderr staje się powodem) albo JSON z permissionDecision: "deny"Kod wyjścia 2 z niepustym powodem na stderr albo decyzja blokująca w JSON
Kod wyjścia 1 lub inny niezerowyBłąd nieblokujący; wywołanie narzędzia przechodziHook oznaczony jako nieudany; wywołanie przechodzi
Brak skryptu lub brak prawa wykonaniaBłąd nieblokujący; wywołanie przechodzi, więc, jak ostrzega dokumentacja Anthropic, literówka w ścieżce w settings.json po cichu wyłącza bramkęTen sam skutek: uruchomienie kończy się błędem i niczego nie blokuje
TimeoutWynik odrzucony, brak decyzji; na zawieszonym hooku nie da się oprzeć bramkiHook oznaczony jako nieudany; brak blokady
Domyślny timeout komendy600 sekund600 sekund
Zmieniona definicja hookaNie zapisuje zaufania dla pojedynczych hooków; o tym, co się uruchamia, decydują scalone ustawieniaNiezarządzany hook ze zmienioną definicją dostaje status zmodyfikowanego i nie uruchamia się, dopóki ktoś ponownie mu nie zaufa

Źródła: dokumentacja hooków Claude Code oraz kod Codex w tagu rust-v0.157.1 (codex-rs/hooks/src/events/pre_tool_use.rs, codex-rs/hooks/src/engine/discovery.rs). Cursor uruchamia hooki jako „spawned processes that communicate over stdio using JSON in both directions”, które „can observe, block, or modify behavior” (dokumentacja hooków Cursora, sprawdzone 2026-08-28). Jego obecnej semantyki błędów nie dało się ponownie zweryfikować na potrzeby tej strony, więc sprawdź ją opisanym niżej skryptem z przypadkami testowymi, zanim zaczniesz na niej polegać.

„Fail closed” nie jest więc ustawieniem. To coś, co musi zrobić kod samego hooka: przechwycić każdą awarię, którą potrafi wykryć, i zakończyć się kodem 2 z powodem. A ponieważ timeout czy crash i tak przepuszczają akcję, hook jest zabezpieczeniem, a nie granicą bezpieczeństwa. Tą granicą jest warstwa uprawnień i sandboxa opisana w uprawnieniach i sandboxingu.

Każdy wspólny hook zaczyna się od kontraktu po review. Commitujesz go obok kodu i scalasz w tym samym review co hook. Skopiuj ten szablon bez zmian.

hooks/guard-env-files/CONTRACT.md
# guard-env-files — contract
Owner: platform team (#platform-hooks). Version: 3. Status: blocking.
Mode switch: GUARD_MODE=advisory logs would-be blocks and exits 0.
## Purpose (one sentence)
Stop agent shell commands that read or write `.env` files.
## Trigger
Event: PreToolUse. Matcher: the shell tool ("Bash" in Claude Code and Codex).
## Input
Reads only `tool_input.command`. Treats the whole input as untrusted text:
never evaluates it, never interpolates it into a shell.
## Authority
No network. No credentials. Reads nothing but stdin.
Writes one log line per run to a local file, never to stdout.
## Decision
Block (exit 2, reason on stderr) when the command references a `.env` file.
Allow (exit 0, no output) otherwise.
## Failure mode: fail closed
Missing jq, unreadable input, or a missing command field → exit 2 with a reason.
Timeout: 10 s. A timeout fails open in every supported tool, so the
permission deny rule on `.env` files stays the real boundary.
## User-visible message
Names the hook, its version, why it blocked, and the doc to read.
## Logs
One JSON line per run in $HOOK_LOG (default
~/.local/state/company-hooks/hooks.jsonl): hook, version, event, mode,
decision, reason_code, session_id, duration_ms. Never the command text. Retention: 30 days.
## Rollback
Managed settings point back to v2. Drill: quarterly. Last drill: (date).

Kontrakt odpowiada na pytania, które recenzent musiałby inaczej wyczytywać ze skryptu: czego hook może dotknąć, co się dzieje, gdy się zepsuje, i jak go wyłączyć. Szablon zostaje po angielsku, bo trafia do repozytorium obok kodu.

Zbuduj jeden rdzeń, który przy awarii blokuje (fail-closed)

Dział zatytułowany „Zbuduj jeden rdzeń, który przy awarii blokuje (fail-closed)”

Oba CLI przekazują na stdin ten sam JSON dla wywołania powłoki w PreToolUse, z komendą w tool_input.command, i oba blokują przy kodzie 2 z powodem na stderr. Jeden skrypt obsługuje więc oba narzędzia, a różni się tylko rejestracja. To ten sam wzorzec co we wspólnych regułach agentów: jeden rdzeń i cienkie, przetestowane adaptery.

#!/usr/bin/env bash
# hooks/guard-env-files/hook.sh — v3 · PreToolUse · shell tool · owner: platform team
set -uo pipefail
MODE=${GUARD_MODE:-blocking} # "advisory" logs would-be blocks and exits 0
LOG=${HOOK_LOG:-$HOME/.local/state/company-hooks/hooks.jsonl}
ms() { local t=${EPOCHREALTIME:-}; if [ -n "$t" ]; then t=${t/[.,]/}; echo $((t / 1000)); else echo $(($(date +%s) * 1000)); fi; }
start=$(ms); sid=unknown
# One JSON line per run, appended to a file: never stdout, never the command text.
log() {
mkdir -p "${LOG%/*}" 2>/dev/null
printf '{"hook":"guard-env-files","version":3,"event":"PreToolUse","mode":"%s","decision":"%s","reason_code":"%s","session_id":"%s","duration_ms":%s}\n' \
"$MODE" "$1" "$2" "$sid" "$(($(ms) - start))" >>"$LOG" 2>/dev/null || true
}
block() {
log block "$1"
printf 'guard-env-files v3: %s See docs/hooks/guard-env-files.md\n' "$2" >&2
[ "$MODE" = advisory ] && exit 0
exit 2
}
command -v jq >/dev/null 2>&1 || block missing_jq "jq is missing, so the check cannot run."
input=$(cat)
sid=$(jq -r '.session_id // "unknown"' <<<"$input" 2>/dev/null | tr -cd '[:alnum:]_-'); sid=${sid:-unknown}
jq -e . >/dev/null 2>&1 <<<"$input" || block malformed_input "the hook input is not valid JSON."
cmd=$(jq -er '.tool_input.command // empty' <<<"$input" 2>/dev/null) || block no_command "the hook input has no command field."
env_re='(^|[[:space:]/="'\''(<>;|&])\.env(\.[[:alnum:]_-]+)?([[:space:]"'\'';|&)<>]|$)'
if printf '%s' "$cmd" | grep -Eq "$env_re"; then
block env_file "a shell command touching a .env file was blocked. Read secrets through the secrets manager instead."
fi
log allow none
exit 0

Cztery cechy odróżniają ten hook od skryptu, który ktoś kiedyś napisał. Każda wykrywalna awaria kończy się kodem 2 z komunikatem, więc laptop bez jq blokuje głośno, zamiast po cichu przepuszczać. Wejście jest zawsze czytane jako dane: nic z niego nie trafia do eval ani do niecytowanego rozwinięcia, a wzorzec łapie też ścieżkę .env, po której stoi separator powłoki, więc cat .env; ls i cat .env&&ls również są blokowane. Każde uruchomienie dopisuje jedną linię JSON do lokalnego pliku logu, nigdy na stdout i nigdy z treścią komendy, i z tych linii liczy się metryki wdrożenia opisane niżej. A komunikat mówi deweloperowi, co się stało i dokąd pójść, i właśnie to powstrzymuje ludzi przed szukaniem obejścia. GUARD_MODE=advisory zachowuje logowanie i komunikat, ale kończy się kodem 0; to pierwszy etap wdrożenia.

Udowodnij działanie hooka przypadkami testowymi, a nie czytaniem kodu

Dział zatytułowany „Udowodnij działanie hooka przypadkami testowymi, a nie czytaniem kodu”

Recenzent nie powinien musieć czytać hook.sh, żeby wiedzieć, że hook działa. Każdy przypadek testowy to katalog z wejściowym JSON-em, oczekiwanym kodem wyjścia, opcjonalnie frazą, którą musi zawierać powód na stderr, i opcjonalnie znacznikiem expect-stdout-empty dla przypadków, które nie mogą niczego wypisać. Skrypt podaje każde wejście do hooka dokładnie tak, jak zrobiłby to agent, przerywa go po 10 sekundach, czyli po timeoucie z kontraktu, i kieruje log do pliku tymczasowego, żeby CI nigdy nie pisało do katalogu domowego.

#!/usr/bin/env bash
# hooks/run-fixtures.sh — usage: hooks/run-fixtures.sh hooks/guard-env-files
# Each fixture dir holds input.json, expect-exit, and optionally expect-stderr
# (a phrase the reason must contain) and expect-stdout-empty (a marker file).
set -u
hook_dir=$1; failed=0
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
export GUARD_MODE=blocking HOOK_LOG="$tmp/hooks.jsonl"
for f in "$hook_dir"/fixtures/*/; do
name=$(basename "$f")
# 10 s is the contract timeout: a hook that needs longer fails here with exit 124.
out=$(timeout 10 "$hook_dir/hook.sh" < "$f/input.json" 2>"$tmp/stderr"); code=$?
want=$(cat "$f/expect-exit")
if [ "$code" != "$want" ]; then
echo "FAIL $name: exit $code, expected $want"; failed=1; continue
fi
if [ -f "$f/expect-stderr" ] && ! grep -qF "$(cat "$f/expect-stderr")" "$tmp/stderr"; then
echo "FAIL $name: stderr did not contain the expected reason"; failed=1; continue
fi
if [ -f "$f/expect-stdout-empty" ] && [ -n "$out" ]; then
echo "FAIL $name: the hook wrote to stdout"; failed=1; continue
fi
echo "ok $name"
done
exit $failed

Uruchamiaj go jako wymagany check CI w repozytorium hooków. Minimalny zestaw przypadków testowych dla każdego hooka blokującego:

PrzypadekWejścieOczekiwany wynik
allow-commonZwykła komenda, np. ls -la srcKod 0, brak wyjścia (expect-stdout-empty)
deny-targetPrzypadek, dla którego hook istnieje, np. cat .env.productionKod 2, stderr podaje nazwę hooka i powód
deny-chainedPrzypadek docelowy, po którym stoi separator powłoki, np. cat .env && lsKod 2
allow-near-missCoś podobnego, ale dozwolonego, np. cat .envrcKod 0 (łapie nadmierne blokowanie)
malformed-inputnot jsonKod 2 (dowód fail-closed)
missing-field{"tool_input":{}}Kod 2
injectionKomenda z $(…), backtickami i cudzysłowamiTa sama decyzja co dla prostej formy; nic się nie wykonuje
slow-dependencyUruchomienie z zależnością podmienioną na zawieszającą sięKończy się w 10-sekundowym timeoucie z kontraktu; jeśli nie, skrypt zgłasza kod 124

Skrypt dowodzi działania rdzenia. Żeby udowodnić działanie adaptera, przepuść te same przypadki testowe raz przez każde narzędzie w roboczym repozytorium i potwierdź, że blokada dociera do agenta. Tylko tak złapiesz błędny matcher albo rejestrację, która nigdy się nie ładuje.

Dystrybuuj hooki tak, żeby uruchamiała się tylko przypięta wersja

Dział zatytułowany „Dystrybuuj hooki tak, żeby uruchamiała się tylko przypięta wersja”

Miejsce instalacji hooka decyduje o tym, kto może go zmienić. Hook w pliku ustawień repozytorium zmienia się za każdym razem, gdy ktoś scali zmianę tego pliku; hook zarządzany zmienia się tylko wtedy, gdy zespół platformowy opublikuje nową wersję. Dla kontroli, które muszą działać, używaj kanału zarządzanego i wersjonowanej ścieżki. Przejście z v2 na v3 jest wtedy jednolinijkową zmianą do review, a powrót to ta sama linia.

Wdrażaj przez ustawienia zarządzane (managed settings): plik managed-settings.json (/etc/claude-code/ na Linuksie i WSL, /Library/Application Support/ClaudeCode/ na macOS, C:\Program Files\ClaudeCode\ na Windowsie), MDM albo ustawienia zarządzane z serwera w konsoli claude.ai w planach Team i Enterprise.

{
"allowManagedHooksOnly": true,
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/opt/company-hooks/guard-env-files/v3/hook.sh", "timeout": 10 }
]
}
]
}
}

allowManagedHooksOnly blokuje hooki użytkownika, projektu, lokalne i z pluginów, z wyjątkiem pluginów wymuszonych w ustawieniach zarządzanych. disableAllHooks ustawione w ustawieniach użytkownika lub projektu nie wyłączy hooków zarządzanych; mogą to zrobić tylko ustawienia zarządzane. Zanim zatwierdzisz plugin, który dostarcza hooki, sprawdź jego zawartość przez claude plugin details <name>. Żeby zobaczyć, jak hooki się ładują i uruchamiają, startuj sesję z claude --debug hooks.

Hook, który od pierwszego dnia blokuje pracę całej firmie, uczy ludzi, jak go obchodzić. Promuj go przez kolejne etapy i pozwól, żeby o każdej promocji decydowały metryki, a nie kalendarz.

  1. Tryb doradczy, jeden zespół. Uruchamiaj hook z GUARD_MODE=advisory, na przykład przez dwulinijkowy wrapper pod wersjonowaną ścieżką, który eksportuje tę zmienną i wywołuje hook.sh, więc działa tak samo w każdym narzędziu. Hook loguje to, co by zablokował, i kończy się kodem 0. Działa tydzień lub dwa, a ty czytasz każdą niedoszłą blokadę.
  2. Blokowanie, jeden zespół. Przełącz na kod 2 dla zespołu ochotników. Kanał zgłaszania wyjątków opublikuj przed przełączeniem.
  3. Blokowanie, pół organizacji. Rozszerz, gdy odsetek fałszywych blokad i opóźnienie przez dwa kolejne tygodnie mieszczą się w ustalonych progach.
  4. Blokowanie, wszyscy. Włącz allowManagedHooksOnly (Claude Code) lub allow_managed_hooks_only (Codex), gdy wszystkie potrzebne kontrole są zarządzane, żeby lokalnie dodane hooki nie mogły ich zasłonić ani zdublować.
  5. Ćwiczenie rollbacku. Przed udostępnieniem wszystkim, a potem co kwartał, przestaw ustawienie zarządzane na poprzednią wersję i potwierdź, że klienci ją przejęli.

Każda metryka ma właściciela i próg, który sam wybierasz; to definicje startowe, nie benchmarki.

MetrykaDefinicjaSygnał problemu
Odsetek fałszywych blokadBlokady uchylone zatwierdzonym wyjątkiem ÷ wszystkie blokady, tygodniowoRosnący odsetek oznacza zbyt szeroką regułę; ludzie zaczną ją obchodzić
Odsetek błędów hookaUruchomienia zakończone kodem niezerowym innym niż 2 lub timeoutem ÷ wszystkie uruchomieniaUtrzymujące się błędy oznaczają, że kontrola gdzieś po cichu nie działa
Opóźnienie p9595. percentyl duration_ms dla zdarzeniaHook uruchamia się przy każdym pasującym wywołaniu narzędzia, więc opóźnienie mnoży się w całej sesji
Znalezione obejściaNaruszenia wykryte później przez CI, review lub skanowanie sekretów, które hook powinien był zablokowaćKażde z nich to brakujący przypadek testowy; dodaj go, zanim zmienisz regułę

Linia logu z kontraktu pozwala policzyć te metryki bez czytania transkryptów: hook, version, event, mode, decision, reason_code, session_id i duration_ms, bez treści komendy.

Nikt nie zatwierdza hooka na podstawie samego czytania kodu. Zatwierdzenie opiera się na artefaktach, które recenzent sprawdzi w kilka minut:

  • Kontrakt jest scalony, z właścicielem i wersją.
  • Skrypt z przypadkami testowymi przechodzi w CI dla rdzenia, a każdy adapter ma zapisany przebieg przez swoje prawdziwe narzędzie.
  • Konfiguracja zarządzana przypina wersjonowaną ścieżkę, a zmiana w niej przeszła review code ownera.
  • Log z etapu doradczego zawiera listę niedoszłych blokad, przeczytaną i zatwierdzoną przez zespół-właściciela.
  • Ostatnie ćwiczenie rollbacku ma datę, a czas od decyzji do przywrócenia klientów jest zapisany.

Właściciel hooka zatwierdza kontrakt i przypadki testowe; lider bezpieczeństwa lub platformy zatwierdza promocję do blokowania i do trybu tylko zarządzanych hooków. Ten podział odpowiada modelowi nadzoru i autonomii agentów: hook jest zabezpieczeniem w pętli agenta, a CI pozostaje bramką, bez której nic nie trafia do gałęzi głównej.

Bramka po cichu nie działa. Zła ścieżka, brakująca zależność albo kod wyjścia 1 dają w obu CLI błąd nieblokujący, a praca toczy się dalej. Naprawa: dodaj przypadki testowe malformed-input i missing-field, niech własne awarie hooka kończą się kodem 2, i alarmuj na podstawie odsetka błędów hooka, zamiast czekać, aż ktoś to zauważy.

Wadliwe wydanie blokuje całą firmę. Wydanie v4 poszerza wzorzec tak, że łapie też src/environment.ts, i każda sesja agenta staje. Naprawa: przestaw ustawienie zarządzane z powrotem na v3, i właśnie dlatego ścieżka jest wersjonowana. W Claude Code disableAllHooks we własnych ustawieniach dewelopera nie wyłączy hooka zarządzanego, więc kanał zarządzany jest jedyną szybką drogą rollbacku; przećwicz ją, zanim będzie potrzebna.

Hook w Codeksie zgasł po aktualizacji. Podbicie wersji w hooks.json repozytorium zmieniło hash definicji i nikt nie zaufał hookowi ponownie. Naprawa: przenieś hooki, które muszą działać, do managed_hooks w requirements.toml; hooki w repozytorium zostaw dla udogodnień, których brak nic nie kosztuje.

Hook uruchomił niezaufany kod w CI. Job z agentem zrobił checkout gałęzi kontrybutora, a hooki z jej .claude/settings.json uruchomiły się z sekretami joba. Naprawa: zaufaną konfigurację pobieraj z gałęzi domyślnej, kod kontrybutora podawaj agentowi wyłącznie do odczytu, a Claude Code uruchamiaj w tym przebiegu z --bare --setting-sources "" --strict-mcp-config, żeby nie załadowały się żadne hooki, ustawienia ani serwery MCP z checkoutu. Samo --settings '{"disableAllHooks": true}' nie wystarczy: serwery MCP i reguły uprawnień z checkoutu nadal działają. Pełny wzorzec opisuje strona AI w CI/CD.

Jeden hook rośnie, aż robi wszystko. Pojedynczy hook „polityki” obrasta w sprawdzanie sekretów, formatowanie i wyszukiwanie ticketów, dostaje dostęp do sieci i token, i nie da się go już testować ani wycofywać po kawałku. Naprawa: podziel go według celu, jeden kontrakt na hook, a wszystko, co wymaga stałego połączenia lub poświadczeń, przenieś do wewnętrznego serwera MCP po review.

Umieść hooki w kontekście reszty wspólnego środowiska agenta (harness): wspólne reguły agentów dla instrukcji i wspólne skills dla procedur. Jedną politykę dla wszystkich uruchamianych agentów opisuje strona polityka zarządzana dla agentów kodujących, a zagrożenia, które hooki pomagają ograniczać, model zagrożeń agenta.