From b3eb27493d94b84b4d59ad9a12b377dae305dfbb Mon Sep 17 00:00:00 2001 From: Daniel Leomil Date: Tue, 6 Oct 2026 19:51:19 -0300 Subject: [PATCH] Reduz aprovacoes Git com limites seguros --- AGENTS.md | 24 ++ .../required-pull-request.json | 25 ++ config/governance-guidelines.json | 7 +- .../github/branching-and-delivery-strategy.md | 18 ++ package.json | 5 +- scripts/codex-safe-git.cjs | 222 ++++++++++++++++++ tests/governance/codex-safe-git.spec.cjs | 84 +++++++ tests/governance/github-pr-ruleset.spec.cjs | 37 +++ 8 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 AGENTS.md create mode 100644 config/github-repository-rulesets/required-pull-request.json create mode 100644 scripts/codex-safe-git.cjs create mode 100644 tests/governance/codex-safe-git.spec.cjs create mode 100644 tests/governance/github-pr-ruleset.spec.cjs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ea11a99 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# Instrucoes do projeto Quiz + +## Operacao eficiente e segura + +- Para operacoes Git rotineiras, use o auxiliar global `codex-safe-git`. +- Push automatizado so e permitido pelo auxiliar para branches temporarias + deste repositorio; nunca faca push direto para `main` ou `develop`. +- Nao use reset, clean, amend, rebase, force-push ou exclusao de branches sem + autorizacao explicita e verificacao do alvo. +- Agrupe leituras e verificacoes independentes e execute + `npm run verify:local` antes de abrir um PR. +- Preserve alteracoes existentes do usuario; nao as inclua em commits sem + autorizacao explicita. +- Use mensagens de commit curtas em portugues ASCII; nao use comentarios em + ingles nem anexe historico automatico. + +## Qualidade e publicacao + +- Conteudo T3 deve respeitar manifesto curricular, cobertura e revisoes + pedagogica e linguistica, alem da aprovacao humana aplicavel. +- Automacao, parecer de agente ou passagem nos checks nao autorizam merge, + release ou deploy por si so. +- Mudancas para `main` exigem PR, checks obrigatorios e autorizacao explicita + de release; mantenha o fluxo de reconciliacao documentado. diff --git a/config/github-repository-rulesets/required-pull-request.json b/config/github-repository-rulesets/required-pull-request.json new file mode 100644 index 0000000..0f74f11 --- /dev/null +++ b/config/github-repository-rulesets/required-pull-request.json @@ -0,0 +1,25 @@ +{ + "name": "Require pull request for main and develop", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/main", "refs/heads/develop"], + "exclude": [] + } + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["merge", "squash"] + } + } + ], + "bypass_actors": [] +} diff --git a/config/governance-guidelines.json b/config/governance-guidelines.json index 13a2f7b..9d6773e 100644 --- a/config/governance-guidelines.json +++ b/config/governance-guidelines.json @@ -56,12 +56,12 @@ }, { "id": "branching-and-delivery", - "version": "1.2.0", + "version": "1.3.0", "status": "active", "type": "policy", "owner": "architecture", "sourcePath": "docs/github/branching-and-delivery-strategy.md", - "sha256": "e8e2a1412b75ed91af5442c817f639aac802df42715edc43bb2af83d40d73f8b", + "sha256": "10d29b7361f105001b08eee0a3a5f3310a0ccef3fedc4507fdff959798d51ca1", "appliesTo": [ "pull-requests", "releases", @@ -74,7 +74,8 @@ "branch sync validation", "merge authorization", "merge message manifest", - "post-merge message audit" + "post-merge message audit", + "restricted local git helper" ] }, { diff --git a/docs/github/branching-and-delivery-strategy.md b/docs/github/branching-and-delivery-strategy.md index e025337..e0f13cc 100644 --- a/docs/github/branching-and-delivery-strategy.md +++ b/docs/github/branching-and-delivery-strategy.md @@ -177,12 +177,30 @@ linguisticos e humanos em ambos os modos. - nao fazer push direto em `main` - nao fazer push direto em `develop` - toda evolucao deve passar por Pull Request +- a ruleset ativa exige Pull Request em `main` e `develop`, inclusive no modo + de mantenedor unico; ela exige zero aprovadores independentes enquanto + vigorar a excecao registrada em #310, sem remover checks ou protecoes atuais - exigir aprovacao independente quando houver ao menos dois mantenedores elegiveis; durante a fase de mantenedor unico, aplicar a excecao rastreada em #310 - usar GitHub Actions como gate minimo de governanca nos PRs - registrar o modo operacional vigente em toda promocao para `main` +## Operacao local com menos aprovacoes + +Para operacoes Git rotineiras, use o auxiliar global `codex-safe-git`, que +aceita somente status, diff de leitura, stage por caminhos explicitos, commit +de caminhos explicitos, troca/criacao de branches e push normal de branches +temporarias deste repositorio. Ele recusa opcoes arbitrarias, amend, reset, +force-push, branches permanentes e destinos diferentes de `dleomil/quiz`. + +O auxiliar reduz prompts do Codex para esses comandos delimitados, mas nao +altera o sandbox do sistema operacional nem as protecoes do GitHub. Nao use +regras globais que liberem todo o comando `git`; preserve a aprovacao humana +para operacoes destrutivas, merges, releases, deploys e decisoes editoriais. +Antes de abrir um PR, rode `npm run verify:local`, que agrupa a suite de testes, +lint, formatacao e verificacao do diff. + ## Motivacao arquitetural Este modelo e propositalmente simples. Criar muitas branches permanentes neste momento aumentaria custo de coordenacao sem gerar ganho proporcional. Para a maturidade atual do projeto, `main` e `develop` sao suficientes. diff --git a/package.json b/package.json index ff63606..201902f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "npm run validate:agents && npm run test:agent-records && npm run test:record-ledger && npm run test:editorial-runner && npm run test:branch-policy && npm run validate:guidelines && npm run test:guidelines && npm run validate:mcp-governance && npm run test:mcp-governance && npm run validate:content && npm run test:content && npm run test:frontend-security && npm run test:pedagogical-regression && npm run test:ui:content-session && npm run test:ui:t3-pilot-draft && npm run test:ui:dark-mode && npm run test:ui:critical-rules && npm run test:ui:trimester-history-selector && npm run test:ui:question-count-selector && npm run test:ui:geography-language-audit && npm run test:ui:english-language-audit && npm run test:ui:portuguese-language-audit && npm run test:ui:panel-base", + "test": "npm run validate:agents && npm run test:agent-records && npm run test:record-ledger && npm run test:editorial-runner && npm run test:branch-policy && npm run test:codex-safe-git && npm run test:github-pr-ruleset && npm run validate:guidelines && npm run test:guidelines && npm run validate:mcp-governance && npm run test:mcp-governance && npm run validate:content && npm run test:content && npm run test:frontend-security && npm run test:pedagogical-regression && npm run test:ui:content-session && npm run test:ui:t3-pilot-draft && npm run test:ui:dark-mode && npm run test:ui:critical-rules && npm run test:ui:trimester-history-selector && npm run test:ui:question-count-selector && npm run test:ui:geography-language-audit && npm run test:ui:english-language-audit && npm run test:ui:portuguese-language-audit && npm run test:ui:panel-base", + "verify:local": "npm test && npm run lint && npm run format:check && git diff --check", + "test:codex-safe-git": "node tests/governance/codex-safe-git.spec.cjs", + "test:github-pr-ruleset": "node tests/governance/github-pr-ruleset.spec.cjs", "validate:agents": "node scripts/validate-codex-agents.cjs && node tests/agents/agent-capability-model.spec.cjs && node tests/agents/codex-agent-validator.spec.cjs", "validate:agent-records": "node scripts/agent-execution-records.cjs", "test:agent-records": "node tests/agents/agent-execution-records.spec.cjs", diff --git a/scripts/codex-safe-git.cjs b/scripts/codex-safe-git.cjs new file mode 100644 index 0000000..faca741 --- /dev/null +++ b/scripts/codex-safe-git.cjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node +'use strict'; + +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const ALLOWED_QUIZ_BRANCH_PREFIXES = [ + 'feature/', + 'fix/', + 'chore/', + 'docs/', + 'refactor/', +]; + +function fail(message) { + const error = new Error(message); + error.code = 'SAFE_GIT_REJECTED'; + throw error; +} + +function validatePaths(paths) { + if (!paths.length) fail('Informe ao menos um caminho depois de --.'); + paths.forEach((filePath) => { + if ( + !filePath || + filePath.startsWith('-') || + filePath.startsWith(':(') || + path.isAbsolute(filePath) || + filePath.split(/[\\/]/).includes('..') + ) { + fail(`Caminho nao permitido: ${filePath}`); + } + }); +} + +function parsePathList(args) { + const separatorIndex = args.indexOf('--'); + if (separatorIndex < 0 || separatorIndex !== 0) { + fail('Use -- antes dos caminhos.'); + } + const paths = args.slice(1); + validatePaths(paths); + return paths; +} + +function validateBranchName(branch) { + if ( + typeof branch !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch) || + branch.includes('..') || + branch.includes('//') || + branch.endsWith('/') || + branch.endsWith('.') || + branch.endsWith('.lock') + ) { + fail(`Nome de branch nao permitido: ${branch}`); + } +} + +function normalizeQuizRemote(remoteUrl) { + if (typeof remoteUrl !== 'string') return null; + const normalized = remoteUrl + .trim() + .replace(/\.git$/i, '') + .replace(/\/$/, '') + .toLowerCase(); + const accepted = [ + 'https://github.com/dleomil/quiz', + 'ssh://git@github.com/dleomil/quiz', + 'git@github.com:dleomil/quiz', + ]; + return accepted.includes(normalized) ? normalized : null; +} + +function authorizeQuizPush({ remoteUrl, branch }) { + if (!normalizeQuizRemote(remoteUrl)) { + fail('Push automatico permitido somente para dleomil/quiz.'); + } + validateBranchName(branch); + if ( + !ALLOWED_QUIZ_BRANCH_PREFIXES.some((prefix) => branch.startsWith(prefix)) + ) { + fail( + 'Push automatico permitido somente para branches temporarias de trabalho.', + ); + } + return ['push', 'origin', `HEAD:refs/heads/${branch}`]; +} + +function resolveCommand(args) { + const [operation, ...rest] = args; + if (!operation) fail('Informe uma operacao segura do Git.'); + + if (operation === 'status' && rest.length === 0) { + return { gitArgs: ['status', '--short', '--branch'] }; + } + if (operation === 'diff') { + if (rest.length === 0) { + return { + gitArgs: ['--no-pager', 'diff', '--no-ext-diff', '--no-textconv'], + }; + } + if (rest.length === 1 && rest[0] === '--cached') { + return { + gitArgs: [ + '--no-pager', + 'diff', + '--no-ext-diff', + '--no-textconv', + '--cached', + ], + }; + } + if (rest.length === 1 && rest[0] === '--check') { + return { + gitArgs: [ + '--no-pager', + 'diff', + '--no-ext-diff', + '--no-textconv', + '--check', + ], + }; + } + if (rest.length === 1 && rest[0] === '--stat') { + return { + gitArgs: [ + '--no-pager', + 'diff', + '--no-ext-diff', + '--no-textconv', + '--stat', + ], + }; + } + fail('diff aceita somente --cached, --check ou --stat.'); + } + if (operation === 'add') { + return { gitArgs: ['add', '--', ...parsePathList(rest)] }; + } + if (operation === 'commit') { + if (rest[0] !== '--message' || !rest[1] || rest[2] !== '--') { + fail( + 'Use commit --message -- ; amend nao e permitido.', + ); + } + const message = rest[1]; + const paths = parsePathList(rest.slice(2)); + return { + gitArgs: ['commit', '--only', '--message', message, '--', ...paths], + }; + } + if (operation === 'switch') { + if (rest.length === 2 && rest[0] === '--create') { + validateBranchName(rest[1]); + return { gitArgs: ['switch', '--create', rest[1]] }; + } + if (rest.length === 1) { + validateBranchName(rest[0]); + return { gitArgs: ['switch', rest[0]] }; + } + fail('Use switch ou switch --create .'); + } + if (operation === 'push-quiz' && rest.length === 0) { + return { operation }; + } + fail(`Operacao Git nao permitida: ${operation}`); +} + +function captureGit(gitArgs, cwd) { + const result = spawnSync('git', gitArgs, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + fail((result.stderr || 'Falha ao consultar o repositorio Git.').trim()); + } + return result.stdout.trim(); +} + +function execute(args, options = {}) { + const cwd = options.cwd || process.cwd(); + const command = resolveCommand(args); + let gitArgs = command.gitArgs; + + if (command.operation === 'push-quiz') { + const remoteUrl = captureGit(['remote', 'get-url', 'origin'], cwd); + const branch = captureGit(['branch', '--show-current'], cwd); + gitArgs = authorizeQuizPush({ remoteUrl, branch }); + } + + const result = spawnSync('git', gitArgs, { + cwd, + stdio: 'inherit', + shell: false, + }); + if (result.error) throw result.error; + return result.status === null ? 1 : result.status; +} + +if (require.main === module) { + try { + process.exitCode = execute(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`codex-safe-git: ${error.message}\n`); + process.exitCode = 2; + } +} + +module.exports = { + ALLOWED_QUIZ_BRANCH_PREFIXES, + authorizeQuizPush, + execute, + normalizeQuizRemote, + parsePathList, + resolveCommand, + validateBranchName, + validatePaths, +}; diff --git a/tests/governance/codex-safe-git.spec.cjs b/tests/governance/codex-safe-git.spec.cjs new file mode 100644 index 0000000..c3c9765 --- /dev/null +++ b/tests/governance/codex-safe-git.spec.cjs @@ -0,0 +1,84 @@ +const assert = require('node:assert/strict'); +const { + authorizeQuizPush, + normalizeQuizRemote, + resolveCommand, +} = require('../../scripts/codex-safe-git.cjs'); + +function expectRejected(args, pattern) { + assert.throws(() => resolveCommand(args), pattern); +} + +function run() { + assert.deepStrictEqual(resolveCommand(['status']).gitArgs, [ + 'status', + '--short', + '--branch', + ]); + assert.deepStrictEqual(resolveCommand(['diff', '--check']).gitArgs, [ + '--no-pager', + 'diff', + '--no-ext-diff', + '--no-textconv', + '--check', + ]); + assert.deepStrictEqual(resolveCommand(['add', '--', 'js/app.js']).gitArgs, [ + 'add', + '--', + 'js/app.js', + ]); + assert.deepStrictEqual( + resolveCommand([ + 'commit', + '--message', + 'Ajusta validacao segura', + '--', + 'scripts/app.js', + ]).gitArgs, + [ + 'commit', + '--only', + '--message', + 'Ajusta validacao segura', + '--', + 'scripts/app.js', + ], + ); + assert.deepStrictEqual( + resolveCommand(['switch', '--create', 'feature/tarefa']).gitArgs, + ['switch', '--create', 'feature/tarefa'], + ); + + expectRejected(['commit', '-m', 'texto'], /--message/); + expectRejected(['commit', '--message', 'texto', '--amend', '--', 'a.js']); + expectRejected(['commit', '--message', 'texto', '--', 'a.js', '--amend']); + expectRejected(['add', '--', '../fora.js'], /Caminho nao permitido/); + expectRejected(['add', '--', ':(top)/**'], /Caminho nao permitido/); + expectRejected(['diff', '--output=/tmp/saida'], /diff aceita somente/); + expectRejected(['reset', '--hard', 'HEAD'], /nao permitida/); + expectRejected(['push', '--force'], /nao permitida/); + + assert.equal( + normalizeQuizRemote('https://github.com/dleomil/quiz.git'), + 'https://github.com/dleomil/quiz', + ); + assert.deepStrictEqual( + authorizeQuizPush({ + remoteUrl: 'git@github.com:dleomil/quiz.git', + branch: 'feature/t3-portugues', + }), + ['push', 'origin', 'HEAD:refs/heads/feature/t3-portugues'], + ); + [ + { remoteUrl: 'https://github.com/other/project.git', branch: 'feature/a' }, + { remoteUrl: 'https://github.com/dleomil/quiz.git', branch: 'main' }, + { remoteUrl: 'https://github.com/dleomil/quiz.git', branch: 'develop' }, + { remoteUrl: 'https://github.com/dleomil/quiz.git', branch: 'release/a' }, + ].forEach((input) => { + assert.throws(() => authorizeQuizPush(input)); + }); + + process.stdout.write('codex-safe-git: ok\n'); +} + +run(); diff --git a/tests/governance/github-pr-ruleset.spec.cjs b/tests/governance/github-pr-ruleset.spec.cjs new file mode 100644 index 0000000..face6b4 --- /dev/null +++ b/tests/governance/github-pr-ruleset.spec.cjs @@ -0,0 +1,37 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ruleset = JSON.parse( + fs.readFileSync( + path.join( + __dirname, + '../../config/github-repository-rulesets/required-pull-request.json', + ), + 'utf8', + ), +); + +assert.equal(ruleset.target, 'branch'); +assert.equal(ruleset.enforcement, 'active'); +assert.deepEqual(ruleset.conditions.ref_name.include, [ + 'refs/heads/main', + 'refs/heads/develop', +]); +assert.deepEqual(ruleset.conditions.ref_name.exclude, []); +assert.deepEqual(ruleset.bypass_actors, []); +assert.deepEqual(ruleset.rules, [ + { + type: 'pull_request', + parameters: { + required_approving_review_count: 0, + dismiss_stale_reviews_on_push: false, + require_code_owner_review: false, + require_last_push_approval: false, + required_review_thread_resolution: true, + allowed_merge_methods: ['merge', 'squash'], + }, + }, +]); + +process.stdout.write('github-pr-ruleset: ok\n');