fix(governance): guarda do plano exige a ponta da main e branch sem toque (#1900)

O sinal do outro lado (GITHEAD_* e tambem um MERGE_HEAD escrito a mao) e
forjavel por quem roda o commit. Com so "indice == outro lado", um commit
comum voltava plan/features.json a versao de um ancestral da main e apagava
features com exit 0 (medido nas duas rotas; a do MERGE_HEAD ja existia na
main). O validate-features ganha as condicoes 6 e 3 do freeze-invariants:
indice == origin/main:plan e HEAD:plan == merge-base:plan.

- casos negativos (d) no vitest para as duas rotas forjadas; sabotagem
  (hook do PR sem as condicoes) deixa os dois vermelhos
- M4 no teste de shell: commit comum com GITHEAD_<ponta> que edita o
  invariante a mao segue recusado
- merge-auto-commit-passa-pelos-guards entra no pnpm test:shell (nao rodava
  no CI)
- fragmento: a guarda citada era a do plano, nao a de migration

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
melgarafael
2026-09-28 22:45:05 -03:00
co-authored by Claude Opus 5.5
parent 36da0deb52
commit dd985ee3f5
5 changed files with 63 additions and 4 deletions
+4 -2
View File
@@ -4,10 +4,12 @@ secao: corrigido
titulo: Dar merge na main deixa de ser acusado como autoria de quem fez o merge
---
Mergear a `main` na sua branch voltou a passar sem válvula nenhuma — e sem afrouxar nada. Quem atualizava a branch com o que a `main` publicou (um invariante reescrito, uma migration nova) era tratado como quem escreveu aquilo: a catraca de invariantes e a guarda de migration liam o índice do merge inteiro e não tinham como separar o que veio da `main` do que a branch introduziu. A causa era do git, não da regra: ele chama o `pre-merge-commit` ANTES de escrever o `MERGE_HEAD`, então as referências de procedência estavam vazias no instante exato em que a pergunta é feita — e a guarda, no lado seguro, falhava fechado sobre o merge todo. Era o lado seguro errado: ela não estava defendendo a regra, estava punindo o merge.
Mergear a `main` na sua branch voltou a passar sem válvula nenhuma — e sem afrouxar nada. Quem atualizava a branch com o que a `main` publicou (um invariante reescrito, uma migration nova) era tratado como quem escreveu aquilo: a catraca de invariantes e a guarda de `plan/features.json` liam o índice do merge inteiro e não tinham como separar o que veio da `main` do que a branch introduziu. A causa era do git, não da regra: ele chama o `pre-merge-commit` ANTES de escrever o `MERGE_HEAD`, então as referências de procedência estavam vazias no instante exato em que a pergunta é feita — e a guarda, no lado seguro, falhava fechado sobre o merge todo. Era o lado seguro errado: ela não estava defendendo a regra, estava punindo o merge.
A regra continua idêntica. O que mudou é como o alcance é calculado: o git entrega o outro lado, naquele instante, em `GITHEAD_<sha>=<ref>`, e é por ali que a guarda agora enxerga de onde veio o que o commit tem. Com o outro lado em mãos, as condições de procedência valem igual no caminho limpo e no caminho conflituoso — o que a `main` já tinha continua não sendo autoria de quem mergeia, o invariante da `main` modificado pela branch continua barrado, e a migration com sequência já usada continua barrada. Nenhuma barreira foi removida, nenhum escape foi retirado: o que some é só a acusação sobre o merge.
Como esse sinal é uma variável de ambiente, quem roda o commit pode forjá-lo, e o mesmo valia para um `MERGE_HEAD` escrito à mão. Por isso a guarda de `plan/features.json` ganhou as duas condições que a catraca de invariantes já tinha: o conteúdo que entra tem de ser o que a `main` tem agora, e a branch não pode ter tocado o arquivo. Antes, um commit comum com o sinal apontando para uma versão antiga da `main` conseguia apagar features do plano.
Não exige ação de ninguém.
Contribuição de @webtecnica (#374).
Contribuição de @webtecnica (#1900).
+17 -1
View File
@@ -56,10 +56,26 @@ if [ -z "$outro_lado" ]; then
fi
fi
# ── (d) e (e): o sinal é FORJÁVEL, então o resultado tem de estar preso ────────
# `GITHEAD_*` é variável de ambiente: quem roda o commit a escreve. E `.git/MERGE_HEAD`
# também se escreve à mão. Com só (a)-(c), um commit COMUM com
# `GITHEAD_<um ancestral da main>=x` voltava o plano à versão daquele ancestral e
# apagava features com exit 0 (medido, nas duas rotas). As duas condições que faltavam
# são as 6 e 3 do freeze-invariants: (d) o índice é o que a main tem AGORA
# (`origin/main`), não qualquer ancestral; (e) esta branch nunca tocou o arquivo
# (`HEAD` == merge-base). Com as duas, o único conteúdo que o sinal libera é o da
# ponta da main sobre uma branch que não mexeu no plano — que é o merge legítimo.
# A sentinela de (e) é a MESMA dos dois lados: "ausente nos dois" (a main criou o
# arquivo depois do ponto da branch) é o caso legítimo, não um descasamento.
if [ -n "$outro_lado" ] \
&& git merge-base --is-ancestor "$outro_lado" origin/main 2>/dev/null \
&& [ "$(git rev-parse -q --verify ':plan/features.json' 2>/dev/null || echo ausente-no-indice)" \
= "$(git rev-parse -q --verify "${outro_lado}:plan/features.json" 2>/dev/null || echo ausente-no-outro-lado)" ]; then
= "$(git rev-parse -q --verify "${outro_lado}:plan/features.json" 2>/dev/null || echo ausente-no-outro-lado)" ] \
&& [ "$(git rev-parse -q --verify ':plan/features.json' 2>/dev/null || echo ausente-no-indice)" \
= "$(git rev-parse -q --verify 'origin/main:plan/features.json' 2>/dev/null || echo ausente-na-ponta)" ] \
&& base_vf=$(git merge-base HEAD "$outro_lado" 2>/dev/null) \
&& [ "$(git rev-parse -q --verify 'HEAD:plan/features.json' 2>/dev/null || echo ausente)" \
= "$(git rev-parse -q --verify "${base_vf}:plan/features.json" 2>/dev/null || echo ausente)" ]; then
exit 0
fi
+1 -1
View File
@@ -26,7 +26,7 @@
"cercas": "vitest run --project cercas",
"test:db": "bash scripts/test-db.sh",
"test:db:update": "bash scripts/test-update-com-dados.sh",
"test:shell": "bash tests/shell/update-guard.test.sh && bash hostgator-setup-kit/test-validators.sh && bash tests/shell/baseline-reaplica-apos-disputa.test.sh && bash tests/shell/dono-do-projeto.test.sh && bash tests/shell/scheduler-entrypoint.test.sh && bash tests/shell/cron-sem-crontab-previo.test.sh && bash tests/shell/senha-do-cron-vazada.test.sh && bash tests/shell/owner-id-por-email.test.sh && bash tests/shell/deskcomm-contribuir.test.sh && bash tests/shell/colisao-de-migration.test.sh && bash tests/shell/test-db-recusa-sem-vitest.test.sh && bash tests/shell/guard-hooks-atualizados.test.sh && bash tests/shell/hooks-nao-acusam-a-main.test.sh && bash tests/shell/env-e2e-porta-do-stack.test.sh && bash tests/shell/instalar-guias.test.sh && bash tests/shell/arquitetura-kit.test.sh && bash tests/shell/guarda-arm-nao-mata-a-recuperacao.test.sh && bash tests/shell/guarda-arm-so-considera-instalacao-real.test.sh && bash tests/shell/extensao-nao-instala.test.sh && bash tests/shell/atualizacao-para-quem-fala-com-o-banco.test.sh && bash tests/shell/desinstalar-docker.test.sh && bash tests/shell/stack-local.test.sh && bash tests/shell/e2e-build-recusa-sem-next.test.sh && bash tests/shell/smoke-llm-recusa-sem-tsx.test.sh && bash tests/shell/waha-backup-volume.test.sh && bash tests/shell/single-server-installer.test.sh && bash tests/shell/single-server-operacao.test.sh && bash tests/shell/ubuntu-production-installer.test.sh && bash tests/shell/atualizacao-de-fora-nao-pula-o-backup.test.sh && bash tests/shell/release-publicada-nao-maior-tag.test.sh",
"test:shell": "bash tests/shell/update-guard.test.sh && bash hostgator-setup-kit/test-validators.sh && bash tests/shell/baseline-reaplica-apos-disputa.test.sh && bash tests/shell/dono-do-projeto.test.sh && bash tests/shell/scheduler-entrypoint.test.sh && bash tests/shell/cron-sem-crontab-previo.test.sh && bash tests/shell/senha-do-cron-vazada.test.sh && bash tests/shell/owner-id-por-email.test.sh && bash tests/shell/deskcomm-contribuir.test.sh && bash tests/shell/colisao-de-migration.test.sh && bash tests/shell/test-db-recusa-sem-vitest.test.sh && bash tests/shell/guard-hooks-atualizados.test.sh && bash tests/shell/hooks-nao-acusam-a-main.test.sh && bash tests/shell/merge-auto-commit-passa-pelos-guards.test.sh && bash tests/shell/env-e2e-porta-do-stack.test.sh && bash tests/shell/instalar-guias.test.sh && bash tests/shell/arquitetura-kit.test.sh && bash tests/shell/guarda-arm-nao-mata-a-recuperacao.test.sh && bash tests/shell/guarda-arm-so-considera-instalacao-real.test.sh && bash tests/shell/extensao-nao-instala.test.sh && bash tests/shell/atualizacao-para-quem-fala-com-o-banco.test.sh && bash tests/shell/desinstalar-docker.test.sh && bash tests/shell/stack-local.test.sh && bash tests/shell/e2e-build-recusa-sem-next.test.sh && bash tests/shell/smoke-llm-recusa-sem-tsx.test.sh && bash tests/shell/waha-backup-volume.test.sh && bash tests/shell/single-server-installer.test.sh && bash tests/shell/single-server-operacao.test.sh && bash tests/shell/ubuntu-production-installer.test.sh && bash tests/shell/atualizacao-de-fora-nao-pula-o-backup.test.sh && bash tests/shell/release-publicada-nao-maior-tag.test.sh",
"test:invariants": "bash scripts/test-db.sh",
"lint:channels": "tsx scripts/lint-channels.ts",
"lint:role-rank": "tsx scripts/lint-role-rank.ts",
@@ -258,5 +258,19 @@ assert_exit "$(exit_de "$r")" 0 "M3: com a válvula declarada o MESMO merge segu
assert_igual "$(pais_de "$m3b")" "2" "M3: e o merge foi mesmo feito"
assert_contains "$(git -C "$m3b" show "HEAD:$INV")" "MARCADOR-MAIN" "M3: e o invariante da main chegou ao HEAD"
# ── M4 · o sinal é FORJÁVEL: um commit COMUM com `GITHEAD_*` não vira merge ───────────
# `GITHEAD_<sha>` é variável de ambiente, e quem roda o commit a escreve. Um `git commit`
# COMUM (sem merge) que edita o invariante à mão, com o sinal apontando para a ponta da main,
# tem de ser recusado como qualquer edição: as condições 3 a 6 do guard prendem o resultado ao
# conteúdo da main, e o sinal sozinho não inocenta nada.
m4="$TMP/m4"; armar "$m4" "$principal" "$BASE"
inv "$SLOT_BRANCH" "$SLOT_MAIN" 'it("MARCADOR-FORJADO", () => {});' > "$m4/$INV"
git -C "$m4" add -A >/dev/null
SHA_M4=$(git -C "$m4" rev-parse HEAD)
r=$( cd "$m4" && env "GITHEAD_$PONTA_MAIN=origin/main" git commit -q -m "edição disfarçada" 2>&1 ); rc=$?
assert_recusa "$rc" "M4: commit comum com GITHEAD_<ponta> forjado que edita o invariante é RECUSADO"
assert_contains "$r" "$INV" "M4: e a mensagem nomeia o invariante"
assert_igual "$(git -C "$m4" rev-parse HEAD)" "$SHA_M4" "M4: e o HEAD ficou PARADO (nada foi commitado)"
printf '\nmerge-auto-commit-passa-pelos-guards: %s casos, %s falha(s)\n' "$casos" "$falhas"
[ "$falhas" -eq 0 ] || exit 1
@@ -246,6 +246,33 @@ describe("hooks não acusam o MERGE da main (#374)", () => {
},
);
/**
* O sinal do outro lado é FORJÁVEL: `GITHEAD_*` é variável de ambiente e
* `.git/MERGE_HEAD` se escreve à mão. Um commit COMUM, numa branch em dia com a
* main, volta o plano à versão de um ANCESTRAL da main (apaga a feature "b") e
* aponta o sinal para esse ancestral. Sem as condições (d) índice == ponta da
* main e (e) HEAD == merge-base, as duas rotas saíam 0 (medido; a do
* MERGE_HEAD já saía 0 na main antes do #374).
*/
for (const rota of ["GITHEAD_<ancestral> forjado", "MERGE_HEAD escrito à mão"]) {
it.skipIf(!temJq)(`(d) plano voltado a um ancestral da main com ${rota} continua BARRADO`, () => {
const { dir, ponta, base } = fixture();
git(dir, "checkout", "-q", "-b", "em-dia", ponta);
git(dir, "checkout", base, "--", PLANO);
git(dir, "add", "--", PLANO);
// premissa: o índice é exatamente o blob do outro lado forjado — a condição
// antiga (índice == outro lado) vale, e é isso que o ataque explora.
expect(git(dir, "rev-parse", `:${PLANO}`)).toBe(git(dir, "rev-parse", `${base}:${PLANO}`));
let env: Record<string, string> = {};
if (rota.startsWith("GITHEAD")) env = { [`GITHEAD_${base}`]: "origin/main" };
else writeFileSync(join(dir, git(dir, "rev-parse", "--git-path", "MERGE_HEAD")), `${base}\n`);
const r = rodar(dir, PLANO_GUARD, env);
expect(r.rc, r.saida.slice(0, 400)).toBe(1);
});
}
it("(a) SABOTAGEM: guard de volta ao comportamento antigo deixa o MESMO estado VERMELHO", () => {
const { dir, ponta } = fixture();
trazERescreve(dir, INV);