diff --git a/README.md b/README.md index 0114a60..17db69c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ A single idempotent bash script that turns a fresh Linux host into a ready-to-us 5. **Brev CLI**, with optional `brev login --api-key ... --org-id ...` when `AAB_BREV_API_KEY` and `AAB_BREV_ORG_ID` are set. 6. **gh CLI**, installed from the official `cli.github.com` apt repo. 7. **git**, with optional author identity, GitHub credential helper, SSH auth key, and SSH signing key. -8. **Agent plugins** listed in [`agent_plugins.txt`](./agent_plugins.txt), installed into both Claude Code and Codex. +8. **Global git-identity enforcement** — an agent rule in every harness's global instruction file plus a global git hook that rejects commits whose identity does not match the configured git config. See [Git Identity Enforcement](#git-identity-enforcement). +9. **Agent plugins** listed in [`agent_plugins.txt`](./agent_plugins.txt), installed into both Claude Code and Codex. ## Requirements @@ -186,9 +187,11 @@ All variables are optional unless you select a provider that needs its credentia | `/etc/environment` | Existing AAB managed blocks are removed so credentials do not remain there. | | `~/.brev/credentials.json` | Written by `brev login --api-key ... --org-id ...` when Brev credentials are configured. | | `~/.brev/onboarding_step.json` | Written to skip the Brev tutorial. | -| `~/.gitconfig` | git identity, GitHub credential helper, and optional SSH signing config. | +| `~/.gitconfig` | git identity, GitHub credential helper, `core.hooksPath` for identity enforcement, and optional SSH signing config. | | `~/.ssh/id_aab_auth`, `~/.ssh/config` | Written only when `AAB_GH_AUTH_SSH_PRIVATE_KEY_B64` is set. | | `~/.ssh/id_aab_signing` | Written only when `AAB_GIT_SSH_SIGNING_PRIVATE_KEY_B64` is set. | +| `~/.aab/git-hooks/` | Global git hook dispatcher and per-hook-name symlinks that enforce the configured commit identity. `core.hooksPath` points here. See [Git Identity Enforcement](#git-identity-enforcement). | +| `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` | Managed block carrying the git-identity agent rule; other content is preserved. | ## SSH Keys @@ -208,6 +211,19 @@ base64 -w0 < ~/.ssh/new_key Set the encoded private key on the relevant AAB variable, and upload the public key to GitHub as either an authentication key, a signing key, or both. +## Git Identity Enforcement + +The bootstrap configures a global git author, email, and (optionally) a commit-signing key, but unattended agents routinely commit under their own identity anyway — via `git -c user.email=...`, `git commit --author=...`, `GIT_AUTHOR_*` / `GIT_COMMITTER_*` environment variables, or a repo-local `git config user.email`. Two layers keep commits on the configured identity: + +1. **An agent rule** is written to each harness's global instruction file — `~/.claude/CLAUDE.md` for Claude Code and `~/.codex/AGENTS.md` for Codex — both of which are loaded in every repository. The rule tells the agent to always commit with the configured identity and to leave the global git config alone. The rule lives inside a managed block (`# >>> autonomous-agent-bootstrap >>>` … `# <<< autonomous-agent-bootstrap <<<`), so re-running the bootstrap replaces it in place and any other content in those files is preserved. +2. **A global git hook** makes the rule non-optional. The bootstrap installs a dispatcher at `~/.aab/git-hooks/aab-git-hook`, symlinks it under each managed hook name, and points `core.hooksPath` at that directory. On `pre-commit` the dispatcher compares the commit's resolved author and committer identity (`git var GIT_AUTHOR_IDENT` / `GIT_COMMITTER_IDENT`) against the global `user.name` / `user.email`, and rejects the commit on a mismatch. When the global config requires signing (`commit.gpgsign=true`), it also rejects commits that disable signing via config or swap the signing key. The expected values are read from `--global`, which a per-invocation `-c`, an environment variable, or a repo-local config cannot override. + +Because a global `core.hooksPath` replaces — rather than supplements — a repository's own `.git/hooks`, the dispatcher chains through to the repo's hook of the same name after its own checks pass, so projects that ship their own hooks (Husky, `pre-commit`, lint-staged, …) keep working. + +The enforcement is intentionally scoped to identity and signing. It does not try to defeat the deliberate per-commit escape hatches git provides — `git commit --no-verify` skips all hooks, and `--no-gpg-sign` skips signing — which remain available for the rare legitimate case. + +If no global `user.name` / `user.email` is configured, the hook is a no-op: there is nothing to enforce against, so all commits pass. + ## Running the Tests All tests are driven by [`./test.bash`](./test.bash). diff --git a/bootstrap.bash b/bootstrap.bash index 442232a..29a76b5 100755 --- a/bootstrap.bash +++ b/bootstrap.bash @@ -65,6 +65,31 @@ SIGNING_KEY="${SSH_DIR}/id_aab_signing" SIGNING_KEY_PUB="${SIGNING_KEY}.pub" SSH_MARKER_BEGIN="# >>> autonomous-agent-bootstrap >>>" SSH_MARKER_END="# <<< autonomous-agent-bootstrap <<<" +GIT_HOOKS_DIR="${AAB_DIR}/git-hooks" +GIT_HOOK_DISPATCHER="${GIT_HOOKS_DIR}/aab-git-hook" +# git hook names the dispatcher is symlinked under. core.hooksPath replaces +# the per-repo hooks dir wholesale, so we cover the hooks git invokes around a +# commit / push and chain through to any repo-local hook of the same name. +GIT_HOOK_NAMES=( + pre-commit + prepare-commit-msg + commit-msg + post-commit + pre-merge-commit + pre-rebase + post-checkout + post-merge + pre-push + post-rewrite + applypatch-msg + pre-applypatch + post-applypatch + sendemail-validate +) +CLAUDE_MEMORY_FILE="${CLAUDE_DIR}/CLAUDE.md" +CODEX_AGENTS_FILE="${CODEX_DIR}/AGENTS.md" +AGENT_RULES_MARKER_BEGIN="# >>> autonomous-agent-bootstrap >>>" +AGENT_RULES_MARKER_END="# <<< autonomous-agent-bootstrap <<<" ETC_ENV="/etc/environment" ETC_ENV_MARKER_BEGIN="# >>> autonomous-agent-bootstrap >>>" ETC_ENV_MARKER_END="# <<< autonomous-agent-bootstrap <<<" @@ -832,6 +857,235 @@ install_signing_ssh_key() { fi } +# --------------------------------------------------------------------------- +# 9c. Install a global git hook that enforces the bootstrap-configured git +# identity (and signing, when configured) on every commit, regardless of the +# repository the agent is working in. +# +# The motivation: agents routinely ignore the global git identity this script +# configures and commit under their own name/email via `git -c user.email=...`, +# `git commit --author=...`, GIT_AUTHOR_*/GIT_COMMITTER_* env vars, or a +# repo-local `git config user.email`. The agent rules written by +# write_agent_git_rules() ask them not to; this hook makes the ask +# non-optional. +# +# emit_git_hook_script writes the dispatcher to stdout so it can be both +# installed and linted (test.bash --lint shellchecks the emitted script). The +# dispatcher reads the expected identity from --global (which -c / env / config +# overrides cannot poison) and the actual identity from `git var`, which does +# reflect --author and GIT_*_ env vars. It then chains through to the repo's +# own hook of the same name so projects that ship hooks keep working — a global +# core.hooksPath replaces the per-repo hooks dir rather than adding to it. +# --------------------------------------------------------------------------- +emit_git_hook_script() { + cat <<'HOOK' +#!/usr/bin/env bash +# autonomous-agent-bootstrap global git hook dispatcher. Installed by +# bootstrap.bash and pointed to by the global core.hooksPath. Every git hook +# name is a symlink to this one script. +# +# On pre-commit it blocks commits whose author / committer identity (and, when +# global signing is on, whose signing config) does not match the global git +# config the bootstrap set up. For every hook it then chains to the +# repository's own .git/hooks/ so project hooks keep running, since a +# global core.hooksPath replaces rather than supplements the per-repo hooks +# directory. +set -uo pipefail + +hook_name=$(basename -- "$0") + +# Extract a field from a `git var GIT_*_IDENT` value, formatted as +# "Name ". +_aab_ident_field() { + case "$2" in + name) printf '%s' "$1" | sed -E 's/ <[^>]*> [0-9]+ [-+][0-9]+$//' ;; + email) printf '%s' "$1" | sed -E 's/.*<([^>]*)> [0-9]+ [-+][0-9]+$/\1/' ;; + esac +} + +# Block the commit unless the author and committer identity match the global +# git config, and unless the globally-configured signing is honored. The +# expected values come from --global, which `git -c`, GIT_CONFIG_PARAMETERS, +# and a repo-local config cannot override; the actual values come from +# `git var`, which reflects `--author` and GIT_AUTHOR_*/GIT_COMMITTER_* env +# vars that the effective `git config user.email` does not. +_aab_enforce_commit_identity() { + local exp_name exp_email + exp_name=$(git config --global --get user.name 2>/dev/null || true) + exp_email=$(git config --global --get user.email 2>/dev/null || true) + + # Nothing pinned in the global config — nothing to enforce. + if [ -z "$exp_name" ] && [ -z "$exp_email" ]; then + return 0 + fi + + local author committer a_name a_email c_name c_email + author=$(git var GIT_AUTHOR_IDENT 2>/dev/null) || return 0 + committer=$(git var GIT_COMMITTER_IDENT 2>/dev/null) || return 0 + a_name=$(_aab_ident_field "$author" name) + a_email=$(_aab_ident_field "$author" email) + c_name=$(_aab_ident_field "$committer" name) + c_email=$(_aab_ident_field "$committer" email) + + local bad=0 + if [ -n "$exp_email" ]; then + [ "$a_email" = "$exp_email" ] || bad=1 + [ "$c_email" = "$exp_email" ] || bad=1 + fi + if [ -n "$exp_name" ]; then + [ "$a_name" = "$exp_name" ] || bad=1 + [ "$c_name" = "$exp_name" ] || bad=1 + fi + if [ "$bad" -ne 0 ]; then + { + echo "[autonomous-agent-bootstrap] Commit blocked: identity does not match the global git config." + echo " expected: ${exp_name} <${exp_email}>" + echo " author: ${a_name} <${a_email}>" + echo " committer: ${c_name} <${c_email}>" + echo " Use the configured identity: plain 'git commit', without -c user.*, --author, or GIT_AUTHOR_*/GIT_COMMITTER_*." + echo " This rule is installed by autonomous-agent-bootstrap. See ~/.claude/CLAUDE.md and ~/.codex/AGENTS.md." + } >&2 + return 1 + fi + + # When the global config requires signing, refuse a commit that disables it + # via config (e.g. `-c commit.gpgsign=false`) or swaps the signing key. The + # per-commit `--no-gpg-sign` flag is invisible to the hook, mirroring how + # `--no-verify` skips hooks entirely. + local exp_sign + exp_sign=$(git config --global --get commit.gpgsign 2>/dev/null || true) + if [ "$exp_sign" = "true" ]; then + local eff_sign exp_key eff_key + eff_sign=$(git config --type=bool --get commit.gpgsign 2>/dev/null || true) + if [ "$eff_sign" != "true" ]; then + echo "[autonomous-agent-bootstrap] Commit blocked: signing is required by the global git config but disabled for this commit." >&2 + return 1 + fi + exp_key=$(git config --global --get user.signingkey 2>/dev/null || true) + eff_key=$(git config --get user.signingkey 2>/dev/null || true) + if [ -n "$exp_key" ] && [ "$eff_key" != "$exp_key" ]; then + echo "[autonomous-agent-bootstrap] Commit blocked: signing key does not match the global git config." >&2 + return 1 + fi + fi + return 0 +} + +if [ "$hook_name" = "pre-commit" ]; then + _aab_enforce_commit_identity || exit 1 +fi + +# Chain to the repository's own hook of the same name. The repo hooks dir is +# located via --git-common-dir (so linked worktrees share the main repo's +# hooks); --git-path is avoided because it honors core.hooksPath and would +# resolve back to this dispatcher. +common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0 +common_dir=$(cd "$common_dir" 2>/dev/null && pwd) || exit 0 +repo_hook="$common_dir/hooks/$hook_name" +if [ -x "$repo_hook" ]; then + self_dir=$(cd "$(dirname -- "$0")" 2>/dev/null && pwd || true) + repo_target=$(readlink -f -- "$repo_hook" 2>/dev/null || echo "$repo_hook") + self_target=$(readlink -f -- "${self_dir}/$(basename -- "$0")" 2>/dev/null || true) + # Skip a repo hook that is just a symlink back to this dispatcher. + if [ "$repo_target" != "$self_target" ]; then + exec "$repo_hook" "$@" + fi +fi +exit 0 +HOOK +} + +install_git_hooks() { + if ! command -v git >/dev/null 2>&1; then + warn "git not installed — skipping git hook enforcement." + return + fi + + mkdir -p "${GIT_HOOKS_DIR}" + local tmp + tmp=$(mktemp "${GIT_HOOK_DISPATCHER}.tmp.XXXXXX") + emit_git_hook_script > "$tmp" + chmod 0755 "$tmp" + mv -f "$tmp" "${GIT_HOOK_DISPATCHER}" + + local name + for name in "${GIT_HOOK_NAMES[@]}"; do + ln -sf "aab-git-hook" "${GIT_HOOKS_DIR}/${name}" + done + + git config --global core.hooksPath "${GIT_HOOKS_DIR}" + log "Installed global git hooks at ${GIT_HOOKS_DIR} and set core.hooksPath (enforces the global commit identity)." +} + +# --------------------------------------------------------------------------- +# 9d. Tell every agent, in its global instruction file, to always commit with +# the bootstrap-configured git identity. Claude Code reads ~/.claude/CLAUDE.md +# and Codex reads ~/.codex/AGENTS.md for every session in every repository, so +# the rule lands regardless of what a project's own CLAUDE.md / AGENTS.md says. +# The rule is wrapped in a managed block so re-runs replace it in place rather +# than stacking, and pre-existing user content in either file is preserved. +# --------------------------------------------------------------------------- +emit_agent_git_rules() { + cat <<'RULES' +## Always use the configured git identity + +This machine is set up by autonomous-agent-bootstrap with a global git author, +email, and (optionally) commit-signing key. Always commit and tag with that +configured identity. + +- Commit with a plain `git commit`. Do not override the identity with + `git -c user.name=... -c user.email=...`, `git commit --author=...`, or the + `GIT_AUTHOR_NAME` / `GIT_AUTHOR_EMAIL` / `GIT_COMMITTER_NAME` / + `GIT_COMMITTER_EMAIL` environment variables, and do not run + `git config user.name` / `git config user.email` inside a repository to set a + different identity. +- Do not change `user.name`, `user.email`, `user.signingkey`, `commit.gpgsign`, + or `core.hooksPath` in the global git config. +- Do not disable commit signing when it is configured (`-c commit.gpgsign=false` + or `--no-gpg-sign`), and do not bypass hooks with `--no-verify`. +- Run `git config --global --get user.name` and `git config --global --get + user.email` if you need to know who you are committing as. + +A global pre-commit hook enforces this and rejects commits whose identity does +not match the global git config. +RULES +} + +write_agent_git_rules() { + _write_agent_rules_block() { + local file="$1" dir + dir=$(dirname -- "$file") + mkdir -p "$dir" + touch "$file" + if grep -qF "${AGENT_RULES_MARKER_BEGIN}" "$file"; then + local tmp + tmp=$(mktemp) + awk -v begin="${AGENT_RULES_MARKER_BEGIN}" -v end="${AGENT_RULES_MARKER_END}" ' + $0 == begin { skip=1; next } + $0 == end { skip=0; next } + !skip { print } + ' "$file" > "$tmp" + # Drop trailing blank lines left behind so the file size stays + # stable across re-runs. + while [ -s "$tmp" ] && [ -z "$(tail -n 1 "$tmp")" ]; do + sed -i '$ d' "$tmp" + done + mv "$tmp" "$file" + fi + { + [ -s "$file" ] && printf '\n' + printf '%s\n' "${AGENT_RULES_MARKER_BEGIN}" + emit_agent_git_rules + printf '%s\n' "${AGENT_RULES_MARKER_END}" + } >> "$file" + } + + _write_agent_rules_block "${CLAUDE_MEMORY_FILE}" + log "Wrote git-identity rule to ${CLAUDE_MEMORY_FILE}." + _write_agent_rules_block "${CODEX_AGENTS_FILE}" + log "Wrote git-identity rule to ${CODEX_AGENTS_FILE}." +} + # --------------------------------------------------------------------------- # 10. Install agent plugins listed in agent_plugins.txt. # @@ -1582,6 +1836,8 @@ main() { configure_git install_auth_ssh_key install_signing_ssh_key + install_git_hooks + write_agent_git_rules install_agent_plugins install_claude_launcher install_codex_launcher diff --git a/test.bash b/test.bash index 0db343e..dea0897 100755 --- a/test.bash +++ b/test.bash @@ -41,6 +41,15 @@ run_lint() { need shellcheck bash -n bootstrap.bash shellcheck -S warning bootstrap.bash test.bash tests/e2e-assertions.bash + # The global git hook is emitted from bootstrap.bash via a quoted heredoc, + # so shellcheck does not see it above. Extract and lint it on its own. + local hook + hook=$(mktemp) + # shellcheck disable=SC1090 + ( set -euo pipefail; source ./bootstrap.bash; emit_git_hook_script ) > "$hook" + bash -n "$hook" + shellcheck -S warning "$hook" + rm -f "$hook" } run_unit() { diff --git a/tests/bootstrap.bats b/tests/bootstrap.bats index d9f5e61..a2637c1 100644 --- a/tests/bootstrap.bats +++ b/tests/bootstrap.bats @@ -1351,6 +1351,230 @@ EOF [ "$(git config --global --get user.signingkey)" = "$SIGNING_KEY_PUB" ] } +# --------------------------------------------------------------------------- +# install_git_hooks / write_agent_git_rules: the global commit-identity +# enforcement hook plus the agent instruction-file rule. Cover: +# - hook dispatcher + per-name symlinks installed, core.hooksPath set +# - the emitted dispatcher is valid bash +# - idempotent re-runs (one rule block, stable symlink count) +# - functional enforcement: a matching identity commits, an overridden one +# is blocked, by every override vector agents reach for +# - the dispatcher chains through to a repo's own hook +# - signing enforcement when global signing is configured +# - the rule block lands in CLAUDE.md / AGENTS.md and preserves prior content +# Each test runs with HOME=$TEST_HOME, so `git config --global` and the hooks +# dir are sandboxed to the per-test home. +# --------------------------------------------------------------------------- + +# Stage a committable repo under $TEST_HOME with the global identity pinned and +# the hooks installed. Echoes the repo path. The bootstrap helpers log to +# stdout, so redirect their chatter to stderr to keep the echoed path clean. +_setup_enforced_repo() { + command -v git >/dev/null || skip "precondition: git must exist" + AAB_GIT_AUTHOR_NAME="Global Name" \ + AAB_GIT_AUTHOR_EMAIL="global@example.com" \ + configure_git >&2 + install_git_hooks >&2 + local repo="$TEST_HOME/repo" + git init -q "$repo" + printf '%s\n' "$repo" +} + +@test "install_git_hooks installs the dispatcher, per-name symlinks, and sets core.hooksPath" { + command -v git >/dev/null || skip "precondition: git must exist" + install_git_hooks + [ -x "$GIT_HOOK_DISPATCHER" ] + [ "$(git config --global --get core.hooksPath)" = "$GIT_HOOKS_DIR" ] + local name + for name in "${GIT_HOOK_NAMES[@]}"; do + [ -L "$GIT_HOOKS_DIR/$name" ] + [ "$(readlink "$GIT_HOOKS_DIR/$name")" = "aab-git-hook" ] + done +} + +@test "emit_git_hook_script emits a syntactically valid bash hook" { + emit_git_hook_script > "$TEST_HOME/hook" + bash -n "$TEST_HOME/hook" + head -1 "$TEST_HOME/hook" | grep -q '^#!/usr/bin/env bash$' +} + +@test "install_git_hooks is idempotent (stable symlink count, hooksPath set once)" { + command -v git >/dev/null || skip "precondition: git must exist" + install_git_hooks + local count1 + count1=$(find "$GIT_HOOKS_DIR" -maxdepth 1 -type l | wc -l) + install_git_hooks + local count2 + count2=$(find "$GIT_HOOKS_DIR" -maxdepth 1 -type l | wc -l) + [ "$count1" -eq "$count2" ] + [ "$count1" -eq "${#GIT_HOOK_NAMES[@]}" ] + [ "$(git config --global --get core.hooksPath)" = "$GIT_HOOKS_DIR" ] +} + +@test "enforcement: a commit with the configured identity is allowed" { + local repo + repo=$(_setup_enforced_repo) + cd "$repo" + echo hi > f.txt && git add f.txt + run git commit -m "matching identity" + [ "$status" -eq 0 ] +} + +@test "enforcement: -c user.email override is blocked" { + local repo + repo=$(_setup_enforced_repo) + cd "$repo" + echo hi > f.txt && git add f.txt + run git -c user.email=hacker@evil.com commit -m "override" + [ "$status" -ne 0 ] + [[ "$output" == *"Commit blocked"* ]] +} + +@test "enforcement: --author override is blocked" { + local repo + repo=$(_setup_enforced_repo) + cd "$repo" + echo hi > f.txt && git add f.txt + run git commit --author="Hacker " -m "override" + [ "$status" -ne 0 ] +} + +@test "enforcement: GIT_AUTHOR_EMAIL env override is blocked" { + local repo + repo=$(_setup_enforced_repo) + cd "$repo" + echo hi > f.txt && git add f.txt + GIT_AUTHOR_EMAIL=hacker@evil.com run git commit -m "override" + [ "$status" -ne 0 ] +} + +@test "enforcement: GIT_COMMITTER_EMAIL env override is blocked" { + local repo + repo=$(_setup_enforced_repo) + cd "$repo" + echo hi > f.txt && git add f.txt + GIT_COMMITTER_EMAIL=hacker@evil.com run git commit -m "override" + [ "$status" -ne 0 ] +} + +@test "enforcement: repo-local user.email override is blocked" { + local repo + repo=$(_setup_enforced_repo) + cd "$repo" + git config user.email hacker@evil.com + git config user.name Hacker + echo hi > f.txt && git add f.txt + run git commit -m "override" + [ "$status" -ne 0 ] +} + +@test "enforcement: --no-verify bypasses the hook (documented escape hatch)" { + local repo + repo=$(_setup_enforced_repo) + cd "$repo" + echo hi > f.txt && git add f.txt + run git -c user.email=hacker@evil.com commit --no-verify -m "bypass" + [ "$status" -eq 0 ] +} + +@test "enforcement: no global identity pinned is a no-op (commit allowed)" { + command -v git >/dev/null || skip "precondition: git must exist" + install_git_hooks + local repo="$TEST_HOME/repo" + git init -q "$repo" + cd "$repo" + # Only a repo-local identity, no global one — nothing to enforce against. + git config user.name "Local Only" + git config user.email "local@only.test" + echo hi > f.txt && git add f.txt + run git commit -m "no global identity" + [ "$status" -eq 0 ] +} + +@test "enforcement: dispatcher chains through to the repo's own hook" { + local repo + repo=$(_setup_enforced_repo) + cd "$repo" + mkdir -p .git/hooks + cat > .git/hooks/pre-commit <<'RH' +#!/usr/bin/env bash +echo "REPO_LOCAL_HOOK_RAN" +exit 0 +RH + chmod +x .git/hooks/pre-commit + echo hi > f.txt && git add f.txt + run git commit -m "with repo hook" + [ "$status" -eq 0 ] + [[ "$output" == *"REPO_LOCAL_HOOK_RAN"* ]] +} + +@test "enforcement: a failing repo-local hook still blocks the commit" { + local repo + repo=$(_setup_enforced_repo) + cd "$repo" + mkdir -p .git/hooks + cat > .git/hooks/pre-commit <<'RH' +#!/usr/bin/env bash +exit 1 +RH + chmod +x .git/hooks/pre-commit + echo hi > f.txt && git add f.txt + run git commit -m "repo hook rejects" + [ "$status" -ne 0 ] +} + +@test "enforcement: signing disabled via config is blocked when global signing is on" { + command -v git >/dev/null || skip "precondition: git must exist" + AAB_GIT_AUTHOR_NAME="Global Name" \ + AAB_GIT_AUTHOR_EMAIL="global@example.com" \ + configure_git + git config --global commit.gpgsign true + git config --global gpg.format ssh + git config --global user.signingkey "$TEST_HOME/fake.pub" + install_git_hooks + local repo="$TEST_HOME/repo" + git init -q "$repo" + cd "$repo" + echo hi > f.txt && git add f.txt + # --no-gpg-sign keeps git from invoking a real signer; the hook should + # still block because the effective commit.gpgsign is false. + run git -c commit.gpgsign=false commit --no-gpg-sign -m "unsigned" + [ "$status" -ne 0 ] + [[ "$output" == *"signing is required"* ]] +} + +@test "write_agent_git_rules writes a managed block to CLAUDE.md and AGENTS.md" { + write_agent_git_rules + [ -f "$CLAUDE_MEMORY_FILE" ] + [ -f "$CODEX_AGENTS_FILE" ] + grep -qF "$AGENT_RULES_MARKER_BEGIN" "$CLAUDE_MEMORY_FILE" + grep -qF "$AGENT_RULES_MARKER_END" "$CLAUDE_MEMORY_FILE" + grep -q "Always use the configured git identity" "$CLAUDE_MEMORY_FILE" + grep -qF "$AGENT_RULES_MARKER_BEGIN" "$CODEX_AGENTS_FILE" + grep -q "Always use the configured git identity" "$CODEX_AGENTS_FILE" +} + +@test "write_agent_git_rules is idempotent (single managed block, size stable)" { + write_agent_git_rules + local size1 + size1=$(wc -c < "$CLAUDE_MEMORY_FILE") + write_agent_git_rules + local begin_count size2 + begin_count=$(grep -cF "$AGENT_RULES_MARKER_BEGIN" "$CLAUDE_MEMORY_FILE") + size2=$(wc -c < "$CLAUDE_MEMORY_FILE") + [ "$begin_count" -eq 1 ] + [ "$size1" -eq "$size2" ] +} + +@test "write_agent_git_rules preserves pre-existing instruction-file content" { + mkdir -p "$(dirname "$CLAUDE_MEMORY_FILE")" + printf '# My memory\n\nKeep this line.\n' > "$CLAUDE_MEMORY_FILE" + write_agent_git_rules + grep -q '^# My memory$' "$CLAUDE_MEMORY_FILE" + grep -q '^Keep this line\.$' "$CLAUDE_MEMORY_FILE" + grep -qF "$AGENT_RULES_MARKER_BEGIN" "$CLAUDE_MEMORY_FILE" +} + # --------------------------------------------------------------------------- # update_etc_environment: removes stale AAB blocks from older installs. # --------------------------------------------------------------------------- diff --git a/tests/e2e-assertions.bash b/tests/e2e-assertions.bash index 11b0311..a8170c8 100644 --- a/tests/e2e-assertions.bash +++ b/tests/e2e-assertions.bash @@ -299,6 +299,61 @@ gh_helper=$(git config --global --get 'credential.https://github.com.helper' || || fail "gh credential helper not registered (got: '$gh_helper')." pass "gh registered as github.com credential helper." +# 12b. The global git-identity enforcement hook is installed and wired via +# core.hooksPath, with a symlink for each managed hook name. +GIT_HOOKS_DIR="${HOME}/.aab/git-hooks" +GIT_HOOK_DISPATCHER="${GIT_HOOKS_DIR}/aab-git-hook" +[ -x "$GIT_HOOK_DISPATCHER" ] || fail "git hook dispatcher not installed at $GIT_HOOK_DISPATCHER." +hooks_path=$(git config --global --get core.hooksPath || true) +[ "$hooks_path" = "$GIT_HOOKS_DIR" ] \ + || fail "core.hooksPath not set to $GIT_HOOKS_DIR (got: '$hooks_path')." +for hook in pre-commit commit-msg pre-push; do + [ -L "$GIT_HOOKS_DIR/$hook" ] \ + || fail "git hook symlink $GIT_HOOKS_DIR/$hook missing." + [ "$(readlink "$GIT_HOOKS_DIR/$hook")" = "aab-git-hook" ] \ + || fail "git hook $hook does not point at the dispatcher." +done +pass "git identity enforcement hook installed and wired via core.hooksPath." + +# 12c. Functional check: a commit with the configured identity is allowed, and +# a commit that overrides the identity is rejected. Runs in a throwaway repo so +# the assertions exercise the real hook end-to-end. +hook_repo=$(mktemp -d) +git init -q "$hook_repo" +( + cd "$hook_repo" + echo enforce > f.txt + git add f.txt + git commit -q -m "matching identity" \ + || { echo "FAIL: commit with the configured identity was rejected." >&2; exit 1; } + echo enforce-more > f.txt + git add f.txt + if git -c user.email="hacker@example.com" -c user.name="Hacker" \ + commit -q -m "overridden identity" 2>/dev/null; then + echo "FAIL: commit overriding the global identity was NOT blocked." >&2 + exit 1 + fi +) || { rm -rf "$hook_repo"; exit 1; } +rm -rf "$hook_repo" +pass "git hook allows the configured identity and blocks overrides." + +# 12d. The agent instruction files carry the git-identity rule in a managed +# block, so Claude Code (~/.claude/CLAUDE.md) and Codex (~/.codex/AGENTS.md) +# both see it in every repository. +CLAUDE_MEMORY_FILE="${HOME}/.claude/CLAUDE.md" +CODEX_AGENTS_FILE="${HOME}/.codex/AGENTS.md" +for rule_file in "$CLAUDE_MEMORY_FILE" "$CODEX_AGENTS_FILE"; do + [ -f "$rule_file" ] || fail "agent rule file $rule_file not written." + grep -q '# >>> autonomous-agent-bootstrap >>>' "$rule_file" \ + || fail "agent rule file $rule_file missing managed-block begin marker." + begin_count=$(grep -c '^# >>> autonomous-agent-bootstrap >>>$' "$rule_file") + [ "$begin_count" -eq 1 ] \ + || fail "agent rule file $rule_file has $begin_count managed blocks, expected 1." + grep -q 'Always use the configured git identity' "$rule_file" \ + || fail "agent rule file $rule_file missing the git-identity rule heading." +done +pass "agent instruction files carry the git-identity rule exactly once." + # 13. /etc/environment does not carry AAB secrets or provider config. ETC_ENV=/etc/environment if [ ! -r "$ETC_ENV" ]; then