Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 13 additions & 9 deletions scripts/lib-core-symlinks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,15 @@ _link_dir() {
}

# Install scripts/<hook_name>.sh as a symlink at .git/hooks/<hook_name>.
# 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 <core_dir> <hook_name>
_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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/role-guard-gen.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
61 changes: 61 additions & 0 deletions scripts/scrub-mirror-history.sh
Original file line number Diff line number Diff line change
@@ -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 <mirror-clone>
# bash scripts/scrub-mirror-history.sh scrub <mirror-clone> # destructive
#
# After scrub:
# cd <mirror-clone> && git push --force --mirror <public-mirror-url>
# 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 <<EOF
Usage:
bash scripts/scrub-mirror-history.sh audit <mirror-clone>
bash scripts/scrub-mirror-history.sh scrub <mirror-clone>

audit — list commits that touched ${TARGET_PATH}
scrub — run git filter-repo --invert-paths (rewrites history in <mirror-clone>)
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
88 changes: 50 additions & 38 deletions tests/install-precommit-hook.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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")" ]
}

# ---------------------------------------------------------------------------
Expand All @@ -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" ]
}

# ---------------------------------------------------------------------------
Expand All @@ -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" ]
}

# ---------------------------------------------------------------------------
Expand All @@ -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"
Expand All @@ -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"
}

Expand All @@ -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" ]
}

Expand All @@ -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" ]
}
19 changes: 19 additions & 0 deletions tests/test_helper.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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/..")"
Expand All @@ -28,6 +45,8 @@ setup() {
# Isolated scratch dir for tests that write files.
SCRATCH="$(mktemp -d)"
export SCRATCH

_leakage_guard_fixture
}

teardown() {
Expand Down
Loading