ConfigChange exit 2 — Verificação ao vivo¶
A pergunta: o hooks/config-guard.sh recusa um reload de settings durante uma run do
Leopold saindo com exit 2 no ConfigChange, e diz ao operador que a sessão portanto
"mantém o wiring de hooks com que começou" — o git lock em primeiro lugar. Isso é uma
afirmação de segurança, e o probe de eventos de hook não conseguia sustentá-la.
Eventos de hook registra a resposta exit2 do ConfigChange como
unobservable: o único exit2 do probe inteiro que não está marcado como honored. O
seu único dado positivo — o arquivo em disco continuava com a edição — é igualmente
verdadeiro tendo o reload sido bloqueado ou adotado, então não prova nada sozinho.
A resposta: o exit 2 bloqueia o reload, e o arquivo mantém a edição. As duas metades estão medidas abaixo, contra um controle, três vezes cada.
Versões¶
| Componente | Versão |
|---|---|
Claude Code CLI (claude) |
2.1.260 (Claude Code) |
jq |
jq-1.7.1-apple |
| SO | Darwin 27.0.0 arm64 |
| Data | 2026-09-04 |
O probe que escreveu Eventos de hook rodou contra a 2.1.259; esta página é um patch depois, e diz isso em vez de tomar emprestado o cabeçalho daquela.
Por que o probe não conseguia ver¶
Um hook de ConfigChange nunca aparece no stream da sessão. Com
--include-hook-events ligado, hook_started / hook_response saíram para
SessionStart, UserPromptSubmit, PreToolUse e Stop — e para mais nada. A string
ConfigChange não ocorre em lugar nenhum do stream de nenhuma das runs abaixo:
$ jq -r 'select(.type=="system" and .subtype=="hook_started") | .hook_event' block3.stream.jsonl | sort | uniq -c
4 PreToolUse
2 SessionStart
2 Stop
2 UserPromptSubmit
$ grep -c ConfigChange block3.stream.jsonl
0
Sem hook_response não há campo exit_code, então o veredito de que toda outra resposta
é lida não existe para esta. Ele tem que ser lido de um efeito colateral.
Método (hermético, pareado)¶
Um projeto descartável por run sob o diretório de scratch da própria sessão, com
git init, um CLAUDE.md e um .claude/settings.json que declara exatamente um hook:
{ "hooks": { "ConfigChange": [ { "hooks": [ { "type": "command",
"command": "bash $P/.claude/hooks/cg.sh", "timeout": 10 } ] } ] } }
O cg.sh anexa a um log o payload que recebeu e então sai com 2 (runs block) ou
0 (runs allow) — a única diferença entre os dois braços.
Ao lado dele fica o settings-v2.json: o mesmo arquivo mais um hook PreToolUse
extra em Bash, cujo script anexa NEW_HOOK_FIRED a um arquivo marcador. Esse hook é o
efeito colateral observável — nada consegue dispará-lo a não ser que a sessão adote os
settings reescritos.
Um turno headless por run, idêntico nos dois braços:
claude -p "Run this exact Bash command: bash rewire.sh. Then run this exact Bash command:
echo SECOND_CALL. Then reply DONE."
--model haiku --permission-mode bypassPermissions
--output-format stream-json [--include-hook-events] --verbose
O rewire.sh copia o settings-v2.json por cima do .claude/settings.json e dorme 4 s.
A sequência é: chamada de ferramenta 1 reescreve os settings → o ConfigChange
dispara e responde → chamada de ferramenta 2 acontece sob o wiring que a sessão tem
agora.
O payload que o ConfigChange entregou, verbatim (run block, caminhos como reportados):
{"session_id":"41421919-ea1a-4f29-b2cf-f43ed7abdbf0","transcript_path":"/Users/jonhvmp/.claude/projects/-private-tmp-…-cgprobe-block/41421919-ea1a-4f29-b2cf-f43ed7abdbf0.jsonl","cwd":"/private/tmp/…/cgprobe/block","scratchpad_dir":"/private/tmp/…/scratchpad","prompt_id":"bb22d94f-47fb-4110-bb65-b9282fd2e991","hook_event_name":"ConfigChange","source":"project_settings","file_path":"/private/tmp/…/cgprobe/block/.claude/settings.json"}
source e file_path são os dois campos que o hooks/config-guard.sh lê, exatamente
como Eventos de hook os registra.
Evidência¶
| Run | exit do ConfigChange |
disparou | 2ª chamada feita | hook novo disparou | edição mantida em disco |
|---|---|---|---|---|---|
allow |
0 | 1 | sim | sim | sim |
allow2 |
0 | 1 | sim | sim | sim |
allow3 |
0 | 1 | sim | sim | sim |
block |
2 | 1 | sim | não | sim |
block2 |
2 | 1 | sim | não | sim |
block3 |
2 | 1 | sim | não | sim |
Os dois braços fizeram as mesmas duas chamadas de ferramenta — a ausência do marcador no
braço block não é ausência da segunda chamada:
=== allow
TOOL Bash :: bash rewire.sh
TOOL Bash :: echo SECOND_CALL
=== block
TOOL Bash :: bash rewire.sh
TOOL Bash :: echo SECOND_CALL
E o stream conta a mesma coisa de forma independente. Existem dois hooks PreToolUse no
nível de usuário nesta máquina, então duas chamadas produzem quatro disparos; os settings
reescritos acrescentam um terceiro hook, que só pode disparar na segunda chamada:
allow3 (exit 0): 5 PreToolUse hook_started ← 4 + o hook recém-declarado
block3 (exit 2): 4 PreToolUse hook_started ← o hook recém-declarado nunca rodou
O arquivo em disco manteve a edição em todas as runs, block incluída:
O que isso decide para o Leopold¶
- O exit 2 do
hooks/config-guard.shfaz o que diz. A sessão em execução não adota a nova configuração; a run mantém o wiring de hooks com que começou, o git lock incluído. A mensagem no stderr voltada ao operador está sustentada. - Ele não desfaz a escrita, e nada neste evento consegue. É por isso que o hook loga
config_change_blockedtão alto quanto recusa: o evento é o registro durável de que o arquivo em disco e a sessão viva divergiram, e oscripts/leopold-watch.pyo renderiza como crítico. - O veredito é um efeito colateral, nunca um campo do stream. O
scripts/probe-hook-events.shagora mede do mesmo jeito: o parconfig-change/config-change-exit2reescreve os settings da sessão para um gêmeo que carrega um hookPreToolUseextra e etiquetado, e conta se ele dispara —config-changeé o controle, e rodarconfig-change-exit2sem ele registra "NO CONTROL" em vez de um veredito. Refaça commake probe-hook-events. - O Codex não é afetado. O Codex CLI 0.152.1 não tem
ConfigChangenenhum (hooks/hook-matrix.tsv), então uma edição doconfig.tomlno meio da run não é detectada lá e oleopold doctordiz isso por harness.