From 2ab110e988a528ef604b517b3e193bdf8f9edf94 Mon Sep 17 00:00:00 2001 From: James Arama Date: Wed, 29 Jul 2026 22:11:38 -0700 Subject: [PATCH] fix(hooks): resolve gitdir for submodule worktrees and close cohort-3 follow-ups Install pre-commit/pre-push hooks via git rev-parse --git-dir so overlay submodule checkouts get the publication gate. Pin synthetic LEAKAGE_* fixtures in test_helper for CI-honest migration tests. Add mirror history scrub helper and fix role-guard-gen exit trap on empty temp-file list. Co-authored-by: Cursor --- CHANGELOG.md | 16 ++++++ scripts/lib-core-symlinks.sh | 22 ++++---- scripts/role-guard-gen.sh | 2 +- scripts/scrub-mirror-history.sh | 61 +++++++++++++++++++++ tests/install-precommit-hook.bats | 88 ++++++++++++++++++------------- tests/test_helper.bash | 19 +++++++ 6 files changed, 160 insertions(+), 48 deletions(-) create mode 100755 scripts/scrub-mirror-history.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fb25fd..6838695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ > **How to update:** The pre-commit hook (`scripts/pre-commit.sh`) is auto-installed by `install.sh`. It runs a fast leakage check (`scripts/check-no-leakage.sh`) and re-renders the generated `CLAUDE.md.generated` and `AGENTS.md.generated` files on every commit. A pre-push gate (`scripts/pre-push.sh`, also auto-installed) scans every outgoing commit's tree and metadata before it leaves the machine. CI runs lint, consult-grammar, and the test suite (including synthetic-token leakage mechanism tests) on every PR; the company-token scan is a separate step that runs only when guard data is present on the runner. If you bypass the hooks or work in a context where hooks cannot run, keep this file current manually. Each release heading links to the diff on the public mirror. +## v1.15.1 — 2026-07-29 (publication-gate follow-ups) + +### Added + +- `scripts/scrub-mirror-history.sh` — audit/scrub helper for removing `scripts/leakage-tokens.txt` from the public mirror's git history (`audit` and `scrub` subcommands; scrub requires `git-filter-repo` and a manual force-push). + +### Changed + +- `scripts/lib-core-symlinks.sh` — `_install_git_hook` resolves the real gitdir via `git rev-parse --git-dir`, so pre-commit and pre-push hooks install in submodule worktrees (`.git` is a file) as well as standalone clones. +- `tests/test_helper.bash` — pins synthetic `LEAKAGE_*` guard fixtures in every test so migration/content leakage checks run on CI instead of silently skipping. +- `tests/install-precommit-hook.bats` — fixtures use real git repos; submodule gitdir install is tested explicitly. + +### Fixed + +- `scripts/role-guard-gen.sh` — empty `_tmpfiles` array no longer triggers an unbound-variable error on exit when no fragments were written. + ## v1.15.0 — 2026-07-29 (externalized leakage gate) ### Added diff --git a/scripts/lib-core-symlinks.sh b/scripts/lib-core-symlinks.sh index 590fe11..6caf3da 100644 --- a/scripts/lib-core-symlinks.sh +++ b/scripts/lib-core-symlinks.sh @@ -64,16 +64,15 @@ _link_dir() { } # Install scripts/.sh as a symlink at .git/hooks/. -# Skips when .git is a file (submodule worktree — hook management belongs to the -# parent repo in that case). Idempotent: removes a pre-existing symlink before -# re-creating it so a re-run after a core_dir path change stays correct. +# Resolves the real gitdir via `git rev-parse --git-dir` so hooks install in +# standalone clones and submodule worktrees (.git is a file pointing elsewhere). +# Idempotent: removes a pre-existing symlink before re-creating it so a re-run +# after a core_dir path change stays correct. # Usage: _install_git_hook _install_git_hook() { local core_dir="$1" local hook_name="$2" - local git_entry="$core_dir/.git" - local hook_src="$core_dir/scripts/${hook_name}.sh" - local hook_dst="$core_dir/.git/hooks/${hook_name}" + local git_dir hook_src hook_dst # Test-isolation bypass: tests/install.bats sets this to prevent writing to # the real .git/hooks/ directory during install.sh integration tests. The @@ -84,12 +83,17 @@ _install_git_hook() { return 0 fi - # Skip submodule worktrees where .git is a file, not a directory. - if [ ! -d "$git_entry" ]; then + if ! git_dir="$(git -C "$core_dir" rev-parse --git-dir 2>/dev/null)"; then return 0 fi + if [[ "$git_dir" != /* ]]; then + git_dir="$core_dir/$git_dir" + fi + + hook_src="$core_dir/scripts/${hook_name}.sh" + hook_dst="$git_dir/hooks/${hook_name}" - mkdir -p "$core_dir/.git/hooks" + mkdir -p "$git_dir/hooks" # Remove existing symlink so the link target stays current. # If a regular file exists (husky, lefthook, hand-written hook), back it up diff --git a/scripts/role-guard-gen.sh b/scripts/role-guard-gen.sh index 934f758..21d4298 100755 --- a/scripts/role-guard-gen.sh +++ b/scripts/role-guard-gen.sh @@ -13,7 +13,7 @@ set -euo pipefail _tmpfiles=() -trap 'rm -f "${_tmpfiles[@]}"' EXIT INT TERM +trap '(( ${#_tmpfiles[@]} )) && rm -f "${_tmpfiles[@]}"' EXIT INT TERM SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/_lib.sh" diff --git a/scripts/scrub-mirror-history.sh b/scripts/scrub-mirror-history.sh new file mode 100755 index 0000000..02fabc1 --- /dev/null +++ b/scripts/scrub-mirror-history.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# scrub-mirror-history.sh — audit and scrub scripts/leakage-tokens.txt from the +# public mirror's git history. +# +# HEAD no longer contains the file (removed in v1.15.0), but past commits still +# expose the denylist. Run this against a fresh mirror clone on a machine that +# may force-push to the public host (some corporate environments block github.com +# pushes from managed workstations — run locally if needed). +# +# Usage: +# bash scripts/scrub-mirror-history.sh audit +# bash scripts/scrub-mirror-history.sh scrub # destructive +# +# After scrub: +# cd && git push --force --mirror +# Notify fork owners; re-tag v1.15.0 if the tag pointed at pre-scrub SHAs. + +set -euo pipefail + +TARGET_PATH='scripts/leakage-tokens.txt' +MODE="${1:-}" +CLONE="${2:-}" + +_usage() { + cat < + bash scripts/scrub-mirror-history.sh scrub + +audit — list commits that touched ${TARGET_PATH} +scrub — run git filter-repo --invert-paths (rewrites history in ) +EOF +} + +_audit() { + echo "=== Commits touching ${TARGET_PATH} ===" + git -C "$CLONE" log --oneline --all -- "$TARGET_PATH" + echo "" + echo "=== Pickaxe sample (set SCRUB_PICKAXE=term to search) ===" + if [ -n "${SCRUB_PICKAXE:-}" ]; then + git -C "$CLONE" log -1 -S "$SCRUB_PICKAXE" --oneline --all -- "$TARGET_PATH" 2>/dev/null || true + fi +} + +_scrub() { + if ! command -v git-filter-repo >/dev/null 2>&1; then + echo "ERROR: git-filter-repo not found. Install: pip install git-filter-repo" >&2 + exit 1 + fi + echo "Rewriting history in $CLONE — removes ${TARGET_PATH} from all commits." + git -C "$CLONE" filter-repo --path "$TARGET_PATH" --invert-paths --force + echo "" + echo "Done. Verify with: git -C \"$CLONE\" log --oneline --all -- $TARGET_PATH" + echo "Then force-push the mirror and re-apply tags as needed." +} + +case "$MODE" in + audit) [ -n "$CLONE" ] || { _usage; exit 1; }; _audit ;; + scrub) [ -n "$CLONE" ] || { _usage; exit 1; }; _scrub ;; + *) _usage; exit 1 ;; +esac diff --git a/tests/install-precommit-hook.bats b/tests/install-precommit-hook.bats index cd8a25f..01ada83 100644 --- a/tests/install-precommit-hook.bats +++ b/tests/install-precommit-hook.bats @@ -9,11 +9,15 @@ setup() { SCRATCH="$(mktemp -d)" export SCRATCH - # Fixture core dir with a real .git directory (not a submodule file) + # Fixture core dir with a real git repository FIXTURE_CORE="$SCRATCH/fixture_core" - mkdir -p "$FIXTURE_CORE/.git/hooks" - # Hook scripts must exist in the fixture so the symlink targets are real mkdir -p "$FIXTURE_CORE/scripts" + git init -q "$FIXTURE_CORE" + FIXTURE_GIT_DIR="$(git -C "$FIXTURE_CORE" rev-parse --git-dir)" + if [[ "$FIXTURE_GIT_DIR" != /* ]]; then + FIXTURE_GIT_DIR="$FIXTURE_CORE/$FIXTURE_GIT_DIR" + fi + export FIXTURE_GIT_DIR cp "$CORE_DIR/scripts/pre-commit.sh" "$FIXTURE_CORE/scripts/pre-commit.sh" cp "$CORE_DIR/scripts/pre-push.sh" "$FIXTURE_CORE/scripts/pre-push.sh" } @@ -41,12 +45,9 @@ teardown() { source "$CORE_DIR/scripts/lib-core-symlinks.sh" _install_precommit_hook "$FIXTURE_CORE" - local hook="$FIXTURE_CORE/.git/hooks/pre-commit" + local hook="$FIXTURE_GIT_DIR/hooks/pre-commit" [ -L "$hook" ] - # Symlink must point at scripts/pre-commit.sh inside fixture core - local target - target="$(readlink "$hook")" - [ "$target" = "$FIXTURE_CORE/scripts/pre-commit.sh" ] + [ "$(realpath "$hook")" = "$(realpath "$FIXTURE_CORE/scripts/pre-commit.sh")" ] } # --------------------------------------------------------------------------- @@ -58,7 +59,7 @@ teardown() { source "$CORE_DIR/scripts/lib-core-symlinks.sh" _install_precommit_hook "$FIXTURE_CORE" - [ -x "$FIXTURE_CORE/.git/hooks/pre-commit" ] + [ -x "$FIXTURE_GIT_DIR/hooks/pre-commit" ] } # --------------------------------------------------------------------------- @@ -71,32 +72,40 @@ teardown() { _install_precommit_hook "$FIXTURE_CORE" _install_precommit_hook "$FIXTURE_CORE" - local hook="$FIXTURE_CORE/.git/hooks/pre-commit" + local hook="$FIXTURE_GIT_DIR/hooks/pre-commit" [ -L "$hook" ] # Still exactly one hook file — not duplicated local count - count="$(find "$FIXTURE_CORE/.git/hooks" -maxdepth 1 -name 'pre-commit*' | wc -l)" + count="$(find "$FIXTURE_GIT_DIR/hooks" -maxdepth 1 -name 'pre-commit' | wc -l)" [ "$count" -eq 1 ] } # --------------------------------------------------------------------------- -# 5. Skips when .git is a file (submodule worktree) +# 5. Submodule worktree: .git is a file — hook installs in resolved gitdir # --------------------------------------------------------------------------- -@test "_install_precommit_hook skips when .git is a file not a directory" { +@test "_install_precommit_hook installs into resolved gitdir when .git is a file" { # shellcheck source=/dev/null source "$CORE_DIR/scripts/lib-core-symlinks.sh" - # Simulate submodule worktree: .git is a file, not a dir - local submod_core="$SCRATCH/submod_core" + local real_repo="$SCRATCH/real_repo" + local gitdir submod_core + git init -q "$real_repo" + gitdir="$(git -C "$real_repo" rev-parse --git-dir)" + if [[ "$gitdir" != /* ]]; then + gitdir="$real_repo/$gitdir" + fi + submod_core="$SCRATCH/submod_core" mkdir -p "$submod_core/scripts" cp "$CORE_DIR/scripts/pre-commit.sh" "$submod_core/scripts/pre-commit.sh" - echo "gitdir: /some/other/.git" > "$submod_core/.git" + printf 'gitdir: %s\n' "$gitdir" > "$submod_core/.git" _install_precommit_hook "$submod_core" - # No hooks directory should have been created - [ ! -d "$submod_core/.git/hooks" ] + local hook="$gitdir/hooks/pre-commit" + [ -L "$hook" ] + [ "$(realpath "$hook")" = "$(realpath "$submod_core/scripts/pre-commit.sh")" ] + [ -x "$hook" ] } # --------------------------------------------------------------------------- @@ -107,7 +116,7 @@ teardown() { # shellcheck source=/dev/null source "$CORE_DIR/scripts/lib-core-symlinks.sh" - local hook="$FIXTURE_CORE/.git/hooks/pre-commit" + local hook="$FIXTURE_GIT_DIR/hooks/pre-commit" # Plant a regular (non-symlink) hook file — simulates husky/lefthook/hand-written hook echo "#!/bin/sh" > "$hook" @@ -117,18 +126,16 @@ teardown() { # install must succeed: hook is now a symlink pointing at pre-commit.sh [ -L "$hook" ] - local target - target="$(readlink "$hook")" - [ "$target" = "$FIXTURE_CORE/scripts/pre-commit.sh" ] + [ "$(realpath "$hook")" = "$(realpath "$FIXTURE_CORE/scripts/pre-commit.sh")" ] # Original regular file must be preserved as a .bak file local bak_count - bak_count="$(find "$FIXTURE_CORE/.git/hooks" -maxdepth 1 -name 'pre-commit.bak.*' 2>/dev/null | wc -l)" + bak_count="$(find "$FIXTURE_GIT_DIR/hooks" -maxdepth 1 -name 'pre-commit.bak.*' 2>/dev/null | wc -l)" [ "$bak_count" -ge 1 ] # Backup must contain the original hook content local bak_file - bak_file="$(find "$FIXTURE_CORE/.git/hooks" -maxdepth 1 -name 'pre-commit.bak.*' 2>/dev/null | head -1)" + bak_file="$(find "$FIXTURE_GIT_DIR/hooks" -maxdepth 1 -name 'pre-commit.bak.*' 2>/dev/null | head -1)" grep -q "existing hook" "$bak_file" } @@ -152,12 +159,9 @@ teardown() { source "$CORE_DIR/scripts/lib-core-symlinks.sh" _install_prepush_hook "$FIXTURE_CORE" - local hook="$FIXTURE_CORE/.git/hooks/pre-push" + local hook="$FIXTURE_GIT_DIR/hooks/pre-push" [ -L "$hook" ] - # Symlink must point at scripts/pre-push.sh inside fixture core - local target - target="$(readlink "$hook")" - [ "$target" = "$FIXTURE_CORE/scripts/pre-push.sh" ] + [ "$(realpath "$hook")" = "$(realpath "$FIXTURE_CORE/scripts/pre-push.sh")" ] [ -x "$hook" ] } @@ -171,30 +175,38 @@ teardown() { _install_prepush_hook "$FIXTURE_CORE" _install_prepush_hook "$FIXTURE_CORE" - local hook="$FIXTURE_CORE/.git/hooks/pre-push" + local hook="$FIXTURE_GIT_DIR/hooks/pre-push" [ -L "$hook" ] # Still exactly one hook file — not duplicated local count - count="$(find "$FIXTURE_CORE/.git/hooks" -maxdepth 1 -name 'pre-push*' | wc -l)" + count="$(find "$FIXTURE_GIT_DIR/hooks" -maxdepth 1 -name 'pre-push' | wc -l)" [ "$count" -eq 1 ] } # --------------------------------------------------------------------------- -# 10. Pre-push install skips when .git is a file (submodule worktree) +# 10. Pre-push install resolves gitdir when .git is a file (submodule worktree) # --------------------------------------------------------------------------- -@test "_install_prepush_hook skips when .git is a file not a directory" { +@test "_install_prepush_hook installs into resolved gitdir when .git is a file" { # shellcheck source=/dev/null source "$CORE_DIR/scripts/lib-core-symlinks.sh" - # Simulate submodule worktree: .git is a file, not a dir - local submod_core="$SCRATCH/submod_core" + local real_repo="$SCRATCH/real_repo" + local gitdir submod_core + git init -q "$real_repo" + gitdir="$(git -C "$real_repo" rev-parse --git-dir)" + if [[ "$gitdir" != /* ]]; then + gitdir="$real_repo/$gitdir" + fi + submod_core="$SCRATCH/submod_core" mkdir -p "$submod_core/scripts" cp "$CORE_DIR/scripts/pre-push.sh" "$submod_core/scripts/pre-push.sh" - echo "gitdir: /some/other/.git" > "$submod_core/.git" + printf 'gitdir: %s\n' "$gitdir" > "$submod_core/.git" _install_prepush_hook "$submod_core" - # No hooks directory should have been created - [ ! -d "$submod_core/.git/hooks" ] + local hook="$gitdir/hooks/pre-push" + [ -L "$hook" ] + [ "$(realpath "$hook")" = "$(realpath "$submod_core/scripts/pre-push.sh")" ] + [ -x "$hook" ] } diff --git a/tests/test_helper.bash b/tests/test_helper.bash index db75629..236938c 100644 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -15,6 +15,23 @@ setup_file() { export DOTFILES_DIR } +# Pin synthetic guard data so check-no-leakage.sh runs on CI (no skip) and +# behaves consistently on developer machines with real guard data installed. +# Individual tests may override LEAKAGE_* for absent-marker / fail-closed cases. +_leakage_guard_fixture() { + GUARD="$SCRATCH/guard" + mkdir -p "$GUARD" + cat > "$GUARD/leakage-tokens.txt" <<'EOF' +# synthetic tokens — mechanism tests only +xyzzy-corp +plugh +xyzzy.example.com +EOF + echo "fixture" > "$GUARD/company-context" + export LEAKAGE_TOKENS_FILE="$GUARD/leakage-tokens.txt" + export LEAKAGE_MARKER_FILE="$GUARD/company-context" +} + setup() { # CORE_DIR is the root of the dotfiles-core repo. CORE_DIR="$(realpath "$BATS_TEST_DIRNAME/..")" @@ -28,6 +45,8 @@ setup() { # Isolated scratch dir for tests that write files. SCRATCH="$(mktemp -d)" export SCRATCH + + _leakage_guard_fixture } teardown() {