From dd985ee3f583f88d9d010079af2a7ba5fe65d83f Mon Sep 17 00:00:00 2001 From: melgarafael Date: Mon, 28 Sep 2026 22:45:05 -0300 Subject: [PATCH] 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_ 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) --- .changes/hooks-nao-acusam-o-merge.md | 6 +++-- loop/hooks/validate-features.sh | 18 ++++++++++++- package.json | 2 +- ...rge-auto-commit-passa-pelos-guards.test.sh | 14 ++++++++++ .../hooks-nao-acusam-o-merge-da-main.test.ts | 27 +++++++++++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/.changes/hooks-nao-acusam-o-merge.md b/.changes/hooks-nao-acusam-o-merge.md index d0409ba19..2ce2d7785 100644 --- a/.changes/hooks-nao-acusam-o-merge.md +++ b/.changes/hooks-nao-acusam-o-merge.md @@ -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_=`, 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). diff --git a/loop/hooks/validate-features.sh b/loop/hooks/validate-features.sh index a4404cfd1..ebc0e09d4 100755 --- a/loop/hooks/validate-features.sh +++ b/loop/hooks/validate-features.sh @@ -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_=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 diff --git a/package.json b/package.json index b376d3866..78e016aee 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/shell/merge-auto-commit-passa-pelos-guards.test.sh b/tests/shell/merge-auto-commit-passa-pelos-guards.test.sh index 3baa9f9ab..ae2ec4056 100755 --- a/tests/shell/merge-auto-commit-passa-pelos-guards.test.sh +++ b/tests/shell/merge-auto-commit-passa-pelos-guards.test.sh @@ -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_` é 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_ 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 diff --git a/tests/unit/hooks-nao-acusam-o-merge-da-main.test.ts b/tests/unit/hooks-nao-acusam-o-merge-da-main.test.ts index 0705244c4..0c9d4a405 100644 --- a/tests/unit/hooks-nao-acusam-o-merge-da-main.test.ts +++ b/tests/unit/hooks-nao-acusam-o-merge-da-main.test.ts @@ -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_ 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 = {}; + 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);