20 Oct 2026 · ia, git, seguranca, ci · 6 min
How to build a safe foundation for a project written entirely by AI
The skeleton of a project an AI wrote comes to 523 lines, and only 132 of them are actual code. The other 391 are the net that keeps the result from going live without passing a test and without another person having looked at it. That disproportion is what stopped a token from landing in the git history.
The point of this post is not that “AI needs more care”. All code needs more care. The point is that an agent writes too fast for a human review at the end to be enough: the code arrives finished, it passes the eye, and the detail that kills sits in a commit nobody read. The way out is to build the lock before, not after.
What the lock needs
The AGENTS.md is read by the agent at the start of every task. The AGENTS.md page explains that it holds what a README does not cover: how to run the tests, the code style, what to do when a rule and a chat request contradict each other.
The commit hook blocks credentials before the commit exists. Git only calls the pre-commit hook if core.hooksPath points at the folder holding it, and only if the file is executable. Miss either and the commit passes in silence. The git hooks manual describes pre-commit as the hook that runs before the commit and can refuse it.
The CI runs the gate and demands review. The job name is what branch protection points at: renaming the job without renaming it in the branch config leaves the rule pointing at a status check that no longer exists. The protected branches documentation lists “require status checks” and “require pull request reviews”, and warns that admins with bypass skip the rule.
The merge lock requires that the review came from someone else: a green status check does not say who looked.
The credential scanner
The scariest piece is the simplest: 14 regular expressions, one per credential format, and a grep per file.
chave-privada:-----BEGIN [A-Z ]*PRIVATE KEY-----
aws-access-key:AKIA[0-9A-Z]{16}
github-token:gh[pousr]_[A-Za-z0-9]{36}
openai-key:sk-[A-Za-z0-9]{20,}
slack-webhook:hooks\.slack\.com/services/[A-Za-z0-9/]+
AKIA followed by 16 uppercase letters or digits is the AWS access key format. ghp_ followed by 36 characters is the GitHub token.
The GitHub secret scanning documentation describes the same mechanism: a set of patterns GitHub scans across the history of the repository, on every branch. In a public repo the scan runs free; in a private one it needs GitHub Secret Protection on Enterprise Cloud.
gitleaks does the same thing with a bigger rule list. It is open source and runs anywhere: GitHub’s native secret scanning comes with the platform, gitleaks comes with you and runs in your CI without depending on the plan.
The artifact
Save it as base-segura.sh and run it with the project name. The script creates the tree, writes every file, installs the hook, runs the gate and proves the lock works by creating a fake file with an AWS key and checking that the scanner finds it.
#!/usr/bin/env bash
# base-segura.sh - builds a safe base repository from scratch for a project
# written by AI.
#
# The script creates the file tree, installs the commit hook that blocks
# credentials, runs the test suite and the credential scan, and proves the
# lock works. Git is optional: if the image has no git, the files are still
# created and the script says what was left out.
#
# bash base-segura.sh [project-name] [owner]
#
# bash base-segura.sh api team
#
# Nothing is installed. The project starts with no dependencies at all, so
# the gate runs in under two seconds and there is no supply chain to attack.
# The only requirement is bash.
set -u
PROJETO="${1:-projeto}"
RESPONSAVEL="${2:-${GIT_OWNER:-desconhecido}}"
RAIZ="$(pwd)"
DESTINO="$RAIZ/$PROJETO"
tem_git() { command -v git >/dev/null 2>&1; }
echo "== 1. creating the tree in $PROJETO =="
mkdir -p "$DESTINO"/src "$DESTINO"/tests "$DESTINO"/scripts "$DESTINO"/.githooks \
"$DESTINO"/.github/workflows "$DESTINO"/revisoes
# Git does not version an empty folder. Without this file the revisoes/
# folder disappears on checkout and the review record goes with it.
: > "$DESTINO/revisoes/.gitkeep"
# --------------------------------------------------------------- AGENTS.md
cat > "$DESTINO/AGENTS.md" <<'AGENTS'
# AGENTS.md
Repository rules for whoever writes code here, human or agent. This file is
read at the start of every task. If a rule here conflicts with a request in
chat, the chat request wins, and the right move is to fix this file
afterwards, not to break the rule in silence.
## What this project is
An example project, with one function in `src/saude.sh` and the suite in
`tests/`. The skeleton is what matters: every new module ships with its own
`tests/<module>.test.sh`.
## How to run the gate
bash scripts/checar.sh
That command is the gate. It runs the test suite and then the credential
scan, and exits 1 if either fails. Run it before any commit and before asking
for a review. There is no other path for anything that did not pass it.
## How to add a test
1. Create `tests/<module>.test.sh` with execute permission.
2. The file defines `teste_*` functions and calls `assert_igual`,
`assert_codigo` and `finaliza`.
3. `scripts/testar.sh` finds the files, runs them and counts.
4. A new module without a matching test does not go in.
## Rules that do not bend
- No secret in the repository. Credentials go into an environment variable,
and the local environment file is in `.gitignore`.
- No `git commit --no-verify`. The commit hook exists to block credentials,
and bypassing it is stupidity, not a shortcut.
- Do not delete or weaken `scripts/segredos.sh` to make the gate pass. If the
scan reports a false positive, fix its regular expression.
- Do not touch `.github/workflows/ci.yml` to disable a step. If the step is
wrong, fix the step.
- A failing test and a missing test are worth the same: zero. A change in
code with no change in test goes back.
- Every commit explains why, not what. The diff already shows what.
## Commit format
`<type>: <short description in the imperative>`, with `type` one of `feat`,
`fix`, `test`, `docs`, `refactor` and `chore`. One line, 72 columns at most.
## What the agent does not do without asking
- Delete a file that has a test.
- Change a port, a route or an environment variable name.
- Add a dependency to the project. The project starts with none, and every
dependency is attack surface and one more file to update.
AGENTS
# --------------------------------------------------------------- gitignore
cat > "$DESTINO/.gitignore" <<'IGNORADO'
# local config and credentials: they never enter the repository
.env
.env.*
*.pem
*.key
*.p12
*.pfx
credentials
credentials.*
secrets/
*_rsa
id_rsa
id_ed25519
# build output and cache
node_modules/
dist/
build/
__pycache__/
*.pyc
.vendor/
vendor/
IGNORADO
# --------------------------------------------------------------- code
cat > "$DESTINO/src/saude.sh" <<'SAUDE'
#!/usr/bin/env bash
# src/saude.sh - the example module of the project.
#
# Two functions: one that builds the target from a URL, which is pure and
# testable without network, and one that actually tests the connection.
set -u
alvo_url() {
# alvo_url <url> -> prints "host port path"
local url="$1" resto host porta caminho
resto="${url#*://}"
host="${resto%%/*}"
porta="${host##*:}"
if [ "$porta" = "$host" ]; then porta=80; fi
host="${host%%:*}"
if [ "$resto" = "${resto#*/}" ]; then
caminho="/"
else
caminho="/${resto#*/}"
fi
# https without an explicit port uses 443 instead of 80
if [ "$url" != "${url#https://}" ] && [ "$porta" = "80" ]; then porta=443; fi
printf '%s %s %s\n' "$host" "$porta" "$caminho"
}
saude() {
# saude <host> <port> -> 0 when it answers, 1 when it does not
local alvo="${1:-localhost}"
local porta="${2:-8080}"
if timeout 3 bash -c "exec 3<>/dev/tcp/$alvo/$porta" 2>/dev/null; then
printf '%s:%s responde\n' "$alvo" "$porta"
return 0
fi
printf '%s:%s nao responde\n' "$alvo" "$porta" >&2
return 1
}
SAUDE
cat > "$DESTINO/tests/ajuda.sh" <<'AJUDA'
#!/usr/bin/env bash
# tests/ajuda.sh - the assertions of the suite.
#
# Every test file sources this one, defines functions that start with
# teste_ and calls finaliza at the end. The failure count and the exit code
# come from here.
set -u
TOTAL=0
FALHAS=0
assert_igual() {
# assert_igual <expected> <got> <description>
TOTAL=$((TOTAL + 1))
if [ "$1" != "$2" ]; then
FALHAS=$((FALHAS + 1))
printf ' FALHOU: %s (esperado "%s", veio "%s")\n' "$3" "$1" "$2" >&2
fi
}
assert_codigo() {
# assert_codigo <expected> <got> <description>
TOTAL=$((TOTAL + 1))
if [ "$1" != "$2" ]; then
FALHAS=$((FALHAS + 1))
printf ' FALHOU: %s (esperado codigo %s, veio %s)\n' "$3" "$1" "$2" >&2
fi
}
finaliza() {
printf ' %d verificacao(oes), %d falha(s)\n' "$TOTAL" "$FALHAS"
[ "$FALHAS" -eq 0 ] || exit 1
exit 0
}
AJUDA
cat > "$DESTINO/tests/saude.test.sh" <<'TESTE'
#!/usr/bin/env bash
# tests/saude.test.sh - test of the src/saude.sh module
#
# Most of the tests need no network. The one that needs a server running
# only runs when the machine has python3; without it the test is skipped and
# that shows up in the score.
set -u
DIRETORIO="$(cd "$(dirname "$0")" && pwd)"
. "$DIRETORIO/ajuda.sh"
. "$DIRETORIO/../src/saude.sh"
teste_url_com_http() {
saida=$(alvo_url "http://exemplo.org/saude")
assert_igual "exemplo.org 80 /saude" "$saida" "http url becomes host, port and path"
}
teste_url_com_https() {
saida=$(alvo_url "https://exemplo.org")
assert_igual "exemplo.org 443 /" "$saida" "https url without path uses port 443"
}
teste_url_com_porta_explicita() {
saida=$(alvo_url "http://exemplo.org:8080/api")
assert_igual "exemplo.org 8080 /api" "$saida" "explicit port is preserved"
}
teste_porta_fechada() {
saida=$(saude 127.0.0.1 9 2>&1)
codigo=$?
assert_codigo 1 "$codigo" "closed port returns code 1"
assert_igual "127.0.0.1:9 nao responde" "$saida" "closed port message"
}
teste_porta_aberta() {
if ! command -v python3 >/dev/null 2>&1; then
printf ' pulado: sem python3 nesta maquina\n'
return 0
fi
python3 -c '
import http.server, threading, time
s = http.server.HTTPServer(("127.0.0.1", 18099), http.server.SimpleHTTPRequestHandler)
threading.Thread(target=s.serve_forever, daemon=True).start()
time.sleep(8)
' &
PID=$!
sleep 1
saida=$(saude 127.0.0.1 18099 2>&1)
codigo=$?
kill "$PID" 2>/dev/null
wait "$PID" 2>/dev/null
assert_codigo 0 "$codigo" "open port returns code 0"
assert_igual "127.0.0.1:18099 responde" "$saida" "open port message"
}
teste_url_com_http
teste_url_com_https
teste_url_com_porta_explicita
teste_porta_fechada
teste_porta_aberta
finaliza
TESTE
# --------------------------------------------------------------- scanner
cat > "$DESTINO/scripts/segredos.sh" <<'SEGREDOS'
#!/usr/bin/env bash
# scripts/segredos.sh - scans the repository for credentials.
#
# bash scripts/segredos.sh [path...]
#
# With no argument it scans the whole project directory. With an argument it
# scans only what was named. It exits 0 when it found nothing and 1 when it
# did.
#
# The regular expressions are the rules. Each line of the PADROES block has
# the service name, a colon, and the expression. What the expression matches
# is the reason the file is on the ignore list.
set -u
# Files the scan does not read. The scanner itself is in there, because its
# own regular expressions would be detected as credentials.
IGNORADOS="scripts/segredos.sh"
PADROES=$(cat <<'FIM'
chave-privada:-----BEGIN [A-Z ]*PRIVATE KEY-----
aws-access-key:AKIA[0-9A-Z]{16}
aws-secret-key:aws_secret_access_key[[:space:]]*=[[:space:]]*[A-Za-z0-9/+=]{40}
github-token:gh[pousr]_[A-Za-z0-9]{36}
github-fine-grained:github_pat_[A-Za-z0-9_]{50,}
openai-key:sk-[A-Za-z0-9]{20,}
anthropic-key:sk-ant-[A-Za-z0-9-]{20,}
google-api-key:AIza[0-9A-Za-z_-]{35}
slack-token:xox[baprs]-[A-Za-z0-9-]{10,}
slack-webhook:hooks\.slack\.com/services/[A-Za-z0-9/]+
stripe-key:[sr]k_(live|test)_[A-Za-z0-9]{20,}
telegram-token:[0-9]{8,10}:[A-Za-z0-9_-]{35}
jwt:eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}
atribuicao-generica:(senha|password|passwd|api[_-]?key|secret|token)[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9/+_.-]{16,}
FIM
)
alvo_principal() {
# the project directory, which is the parent of scripts/
cd "$(dirname "$0")/.." && pwd
}
ignorado() {
# ignorado <path> -> 0 when the file should not be read
local arquivo="$1" ig
for ig in $IGNORADOS; do
case "$arquivo" in
"$ig"|*/"$ig") return 0 ;;
esac
done
return 1
}
procurar() {
# returns 0 when it found a credential, 1 when it did not
local raiz="$1" linha nome padrao arquivo achou=1
while IFS= read -r linha; do
[ -n "$linha" ] || continue
nome="${linha%%:*}"
padrao="${linha#*:}"
[ -n "$padrao" ] || continue
while IFS= read -r arquivo; do
[ -n "$arquivo" ] || continue
ignorado "$arquivo" && continue
if grep -Eq "$padrao" "$arquivo" 2>/dev/null; then
printf 'CREDENCIAL [%s] em %s (linha %s)\n' "$nome" "$arquivo" \
"$(grep -En "$padrao" "$arquivo" 2>/dev/null | head -1 | cut -d: -f1)" >&2
achou=0
fi
done < <(find "$raiz" -type f -not -path "*/.git/*" -not -path "*/revisoes/*" 2>/dev/null | sort)
done <<< "$PADROES"
return $achou
}
if [ "$#" -gt 0 ]; then
ALVO="$1"
else
ALVO="$(alvo_principal)"
fi
echo "credential scan in $ALVO"
if procurar "$ALVO"; then
echo "scan: credential found, the commit was blocked" >&2
exit 1
fi
echo "scan: no credential found"
exit 0
SEGREDOS
# --------------------------------------------------------------- tests
cat > "$DESTINO/scripts/testar.sh" <<'TESTAR'
#!/usr/bin/env bash
# scripts/testar.sh - runs every tests/*.test.sh file and counts.
set -u
DIRETORIO="$(cd "$(dirname "$0")/.." && pwd)"
cd "$DIRETORIO" || exit 1
TOTAL_ARQUIVOS=0
TOTAL_FALHAS=0
for arquivo in tests/*.test.sh; do
[ -e "$arquivo" ] || continue
TOTAL_ARQUIVOS=$((TOTAL_ARQUIVOS + 1))
echo "--- $arquivo"
bash "$arquivo" || TOTAL_FALHAS=$((TOTAL_FALHAS + 1))
done
if [ "$TOTAL_ARQUIVOS" -eq 0 ]; then
echo "no test file in tests/" >&2
exit 1
fi
echo "tests: $TOTAL_ARQUIVOS file(s), $TOTAL_FALHAS with failures"
[ "$TOTAL_FALHAS" -eq 0 ] || exit 1
exit 0
TESTAR
cat > "$DESTINO/scripts/checar.sh" <<'CHECAR'
#!/usr/bin/env bash
# scripts/checar.sh - the gate. Runs the tests and the credential scan.
#
# bash scripts/checar.sh
#
# It exits 0 when both passed and 1 when either failed. This is the command
# the CI runs and the commit hook calls.
set -u
DIRETORIO="$(cd "$(dirname "$0")/.." && pwd)"
cd "$DIRETORIO" || exit 1
echo "== testes =="
bash scripts/testar.sh
TESTES=$?
echo
echo "== credential scan =="
bash scripts/segredos.sh
SEGREDOS=$?
echo
if [ "$TESTES" -eq 0 ] && [ "$SEGREDOS" -eq 0 ]; then
echo "gate: passed"
exit 0
fi
echo "gate: refused (tests=$TESTES, scan=$SEGREDOS)" >&2
exit 1
CHECAR
# --------------------------------------------------------------- merge
cat > "$DESTINO/scripts/merge.sh" <<'MERGE'
#!/usr/bin/env bash
# scripts/merge.sh - the review lock before merging.
#
# bash scripts/merge.sh <branch> [--executar]
#
# The script only lets the merge through when three conditions hold at the
# same time: the gate passes, there is a review recorded for the current
# commit of the branch, and whoever reviewed is not the author of the commit.
# Without --executar it just says what is missing.
set -u
BRANCHE="${1:-}"
EXECUTAR="${2:-}"
DIRETORIO="$(cd "$(dirname "$0")/.." && pwd)"
cd "$DIRETORIO" || exit 1
if [ -z "$BRANCHE" ]; then
echo "usage: bash scripts/merge.sh <branch> [--executar]" >&2
exit 2
fi
falha=0
echo "== 1. gate =="
if bash scripts/checar.sh >/dev/null 2>&1; then
echo "ok: tests and scan passed"
else
echo "FAILED: the gate refused" >&2
falha=1
fi
echo
echo "== 2. recorded review =="
COMMIT="$(git rev-parse "$BRANCHE" 2>/dev/null)"
if [ -z "$COMMIT" ]; then
echo "FAILED: branch $BRANCHE does not exist" >&2
falha=1
else
ARQUIVO="revisoes/$BRANCHE.txt"
if [ ! -f "$ARQUIVO" ]; then
echo "FAILED: no review in $ARQUIVO" >&2
echo " the reviewer creates the file with: reviewer <commit> <commit-author>" >&2
falha=1
else
REVISOR="$(awk 'NR==1 {print $1}' "$ARQUIVO")"
REVISADO="$(awk 'NR==1 {print $2}' "$ARQUIVO")"
AUTOR="$(git log -1 --format='%an' "$COMMIT" 2>/dev/null)"
if [ "$REVISADO" != "$COMMIT" ]; then
echo "FAILED: the review is of commit $REVISADO, the branch is at $COMMIT" >&2
falha=1
elif [ "$REVISOR" = "$AUTOR" ]; then
echo "FAILED: the reviewer ($REVISOR) is the author of the commit" >&2
falha=1
else
echo "ok: $REVISOR reviewed commit $COMMIT"
fi
fi
fi
echo
if [ "$falha" -ne 0 ]; then
echo "merge: blocked" >&2
exit 1
fi
echo "merge: released"
if [ "$EXECUTAR" = "--executar" ]; then
git merge --no-ff "$BRANCHE" -m "merge de $BRANCHE"
echo "merge done"
fi
exit 0
MERGE
# --------------------------------------------------------------- hook
cat > "$DESTINO/.githooks/pre-commit" <<'GANCHO'
#!/usr/bin/env bash
# .githooks/pre-commit - blocks credentials before the commit.
#
# Git only calls this hook if core.hooksPath points at .githooks. The
# base-segura.sh script does that at install time.
set -u
DIRETORIO="$(cd "$(dirname "$0")/.." && pwd)"
if bash "$DIRETORIO/scripts/segredos.sh" >/dev/null 2>&1; then
exit 0
fi
echo "COMMIT BLOCKED: credential found in what would be committed." >&2
echo "Run 'bash scripts/segredos.sh' to see where it is." >&2
echo "Do not use --no-verify. The credential goes into an env var." >&2
exit 1
GANCHO
# Git only runs the hook if it is executable. Without the chmod, git warns
# that it ignored the hook and the commit goes through unscanned.
chmod +x "$DESTINO/.githooks/pre-commit" \
"$DESTINO"/scripts/*.sh \
"$DESTINO"/tests/*.sh \
"$DESTINO"/src/*.sh
# --------------------------------------------------------------- CI
cat > "$DESTINO/.github/workflows/ci.yml" <<'CI'
# .github/workflows/ci.yml - the CI of the project.
#
# Two jobs, and both have to pass:
# portao runs the tests and the credential scan
# revisao on a pull request, demands an approved review from someone else
#
# The job name is what branch protection requires. If you rename the job,
# rename it in the branch config too, otherwise the rule points at a status
# check that does not exist.
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, submitted]
permissions:
contents: read
jobs:
portao:
name: portao
runs-on: ubuntu-latest
steps:
- name: checkout the code
uses: actions/checkout@v4
- name: run the tests and the credential scan
run: bash scripts/checar.sh
- name: show what was verified
run: ls -la scripts/ tests/ AGENTS.md .github/workflows/ci.yml
revisao:
name: revisao aprovada
runs-on: ubuntu-latest
# on a direct push to main there is no pull request, and nothing to require
if: github.event_name == 'pull_request'
steps:
- name: demand a review from another person
env:
ACAO: ${{ github.event.action }}
ESTADO: ${{ github.event.review.state }}
AUTOR: ${{ github.event.pull_request.user.login }}
REVISOR: ${{ github.event.review.user.login }}
run: |
if [ "$ACAO" != "submitted" ]; then
echo "there is no review for this pull request yet"
exit 1
fi
if [ "$ESTADO" != "approved" ]; then
echo "review not approved (state: $ESTADO)"
exit 1
fi
if [ "$REVISOR" = "$AUTOR" ]; then
echo "the reviewer is the author of the pull request"
exit 1
fi
echo "review approved by $REVISOR"
CI
# --------------------------------------------------------------- README
cat > "$DESTINO/README.md" <<'LEIA'
# $PROJETO
Example project, built by `base-segura.sh`.
## Before anything else
bash scripts/checar.sh
That is the gate. Tests, credential scan, and exit 1 when something fails.
## Layout
AGENTS.md the rules the agent and the human follow
.githooks/ the commit hook that blocks credentials
.github/workflows/ the CI
scripts/ gate, test suite and scanner
src/ the code
tests/ one test file per module
revisoes/ review record, one file per branch
## Golden rule
The gate decides what goes in. If it refused, what is missing is in its output.
LEIA
# --------------------------------------------------------------- git
if tem_git; then
echo
echo "== 2. installing the repository and the commit hook =="
cd "$DESTINO" || exit 1
git init -q -b main
git config core.hooksPath .githooks
git config user.name "$RESPONSAVEL"
git config user.email "$RESPONSAVEL@localhost"
git add -A
git -c commit.gpgsign=false commit -q -m "chore: esqueleto do projeto com portao, varredura e gancho"
echo "repository created, hook installed (core.hooksPath = .githooks)"
echo "commits: $(git rev-list --count HEAD)"
else
echo
echo "== 2. git is not in this image: the files were created, the repo was not =="
echo " git init -b main"
echo " git config core.hooksPath .githooks"
echo " git add -A && git commit -m 'chore: esqueleto do projeto'"
fi
# --------------------------------------------------------------- proof
echo
echo "== 3. running the gate =="
cd "$DESTINO" || exit 1
bash scripts/checar.sh
PORTACAO=$?
echo
echo "== 4. proving the credential lock works =="
PASTA_PROVA="$(mktemp -d)"
cat > "$PASTA_PROVA/vazamento.txt" <<'VAZAMENTO'
# test file: simulates someone leaving an AWS token in the code
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
VAZAMENTO
echo "fake file created at $PASTA_PROVA/vazamento.txt"
if bash scripts/segredos.sh "$PASTA_PROVA" >/dev/null 2>&1; then
echo "ERROR: the scan did NOT find the fake credential"
PROVA_FALHOU=1
else
echo "the scan found the fake credential, as it should:"
bash scripts/segredos.sh "$PASTA_PROVA" 2>&1 | sed 's/^/ /'
PROVA_FALHOU=0
fi
rm -rf "$PASTA_PROVA"
echo
echo "== 5. tree created =="
cd "$DESTINO" || exit 1
find . -type f -not -path "./.git/*" | sort | sed 's/^/ /'
echo
echo "summary: gate=$PORTACAO, lock=$PROVA_FALHOU"
if [ "$PORTACAO" -eq 0 ] && [ "$PROVA_FALHOU" -eq 0 ]; then
echo "project ready"
exit 0
fi
exit 1
How to verify it
Run the script in an empty folder.
bash base-segura.sh api team
== 3. running the gate ==
--- tests/saude.test.sh
7 verificacao(oes), 0 falha(s)
testes: 1 arquivo(s), 0 com falha
== credential scan ==
scan: no credential found
gate: passed
== 4. proving the credential lock works ==
the scan found the fake credential, as it should:
CREDENCIAL [aws-access-key] em /tmp/tmp.aq2UIfopD9/vazamento.txt (linha 2)
summary: gate=0, lock=0
project ready
The gate takes 1.6 seconds. With 523 lines, the skeleton fits in a repository anyone can clone in a second.
The hook blocking a file with a real credential shows up in the full test:
COMMIT BLOCKED: credential found in what would be committed.
Do not use --no-verify. The credential goes into an env var.
And the merge lock blocking in three situations: no review recorded, a review by the author himself, and released only with a reviewer from someone else.
When it goes wrong
The commit goes through with a credential inside and git warns that it ignored the hook. Cause: the hook is not executable, or core.hooksPath does not point at .githooks. Fix: chmod +x .githooks/pre-commit and git config core.hooksPath .githooks.
The scan flags the scanner itself. Cause: its regular expressions live in a file it scans. Fix: the script already ignores scripts/segredos.sh, and the IGNORADOS list at the top is where you add more exclusions.
The branch cannot be merged even with the gate green. Cause: the review is of an earlier commit and the branch has moved. Fix: the lock compares the reviewed commit against the branch HEAD.
Comparison table
| Piece | What it holds | Cost | When to use it |
|---|---|---|---|
| AGENTS.md | the rule the agent follows | free, one file | always |
| commit hook | credentials in a local commit | free, bash | always |
| scan in CI | credentials in the history | free GitHub runners | public repo or paid plan |
| gitleaks | the same, with rules ready | free, open source | when you do not want to maintain the list |
| protected branch | merge without test and without review | free on a public repo | main branch |
FAQ
Why AGENTS.md and not a README? A README is for people: quick start, project description. AGENTS.md is the extra context an agent needs, and it clutters the README for anyone who just wants to use the project.
Do I need a paid GitHub account? No. A public repo gets secret scanning and branch protection for free. Only Secret Protection on an org-owned private repo needs a paid plan.
Isn’t human review the bottleneck? It is, and that is the point. The lock asks for a review of every change, not every line. The one who labels their own pull request is who it blocks.
And if the agent asks to turn the lock off? AGENTS.md says no, and the hook answers to nobody. For a temporary credential, the path is an environment variable plus .env in gitignore.
Sources
The AGENTS.md page, from the Agentic AI Foundation, checked on 20 Oct 2026. The protected branches documentation and the secret scanning documentation, from GitHub Docs. The githooks manual, from git-scm.
The habit of measuring before concluding is in the set of 20 checkable questions. And the other side of a credential in a repository is in what the password 123456 cost.