diff options
| -rw-r--r-- | .ai/protocols.org | 2 | ||||
| -rw-r--r-- | claude-templates/.ai/protocols.org | 2 | ||||
| -rwxr-xr-x | scripts/sweep-gitignore-tooling.sh | 57 | ||||
| -rw-r--r-- | scripts/tests/sweep-gitignore-tooling.bats | 67 |
4 files changed, 119 insertions, 9 deletions
diff --git a/.ai/protocols.org b/.ai/protocols.org index ed07c0e..5e18ab9 100644 --- a/.ai/protocols.org +++ b/.ai/protocols.org @@ -552,6 +552,8 @@ Claude needs to add information to =.ai/notes.org=. For large amounts of informa **The gitignore set follows that same decision.** A project that gitignores =.ai/= (the code-project case) gitignores the whole personal-tooling set: =.ai/=, =.claude/=, =CLAUDE.md=, =AGENTS.md=. =.claude/= is rulesets-owned — copies of =claude-rules/*.md= plus the language bundle's rules, hooks, and settings — and re-synced from rulesets on every startup, so git isn't how it travels between machines; ignoring it also keeps those private rule copies out of the repo, which ignoring =CLAUDE.md= alone would miss. A track-mode project (personal/doc repos, or a team repo that shares config with teammates who don't run rulesets) tracks the set instead. =install-ai.sh= writes the full set at bootstrap in gitignore mode; =scripts/sweep-gitignore-tooling.sh= backfills it idempotently across existing gitignore-mode projects when the set grows. +**Public reachability decides harder than project type.** Any repo whose remotes include a non-cjennings.net host gitignores the tooling set, whatever kind of project it is — the only exception is a team repo that deliberately shares the config, decided explicitly, never by default. And a private remote is not proof of privacy: a server-side =post-receive --mirror= hook republishes invisibly from the client (the 2026-06-30 =.emacs.d= exposure rode exactly that — a cjennings.net remote mirroring to public GitHub). The sweep recognizes both the anchored (=/.ai/=) and unanchored (=.ai/=) ignore styles — an anchored-style project used to be misread as track-mode and silently skipped — and warns when tracked tooling can reach a non-cjennings.net remote. + **Credential-leak concern: gate it on project type, not on the credential itself.** A tracked secret, token, or credentials doc is only a public-leak risk where the repo can reach a public remote — that is, *code projects pushed to public GitHub*, which is exactly why those gitignore =.ai/= and =.claude/=. For *personal / documentation projects* (the =~/projects/= set: elibrary, home, finances, health, philosophy, etc.), the git remote is a private single-user repo on =cjennings.net=, so tracked credentials inside =.ai/= files are fine — that's the design, the project history IS the project. Do NOT raise a leak warning or suggest gitignoring a secret for these. When the question "is this a leak / should we gitignore this secret?" comes up, decide it on *which kind of project and remote* this is, never on the mere presence of a credential in a tracked file. **When to break out documents:** diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org index ed07c0e..5e18ab9 100644 --- a/claude-templates/.ai/protocols.org +++ b/claude-templates/.ai/protocols.org @@ -552,6 +552,8 @@ Claude needs to add information to =.ai/notes.org=. For large amounts of informa **The gitignore set follows that same decision.** A project that gitignores =.ai/= (the code-project case) gitignores the whole personal-tooling set: =.ai/=, =.claude/=, =CLAUDE.md=, =AGENTS.md=. =.claude/= is rulesets-owned — copies of =claude-rules/*.md= plus the language bundle's rules, hooks, and settings — and re-synced from rulesets on every startup, so git isn't how it travels between machines; ignoring it also keeps those private rule copies out of the repo, which ignoring =CLAUDE.md= alone would miss. A track-mode project (personal/doc repos, or a team repo that shares config with teammates who don't run rulesets) tracks the set instead. =install-ai.sh= writes the full set at bootstrap in gitignore mode; =scripts/sweep-gitignore-tooling.sh= backfills it idempotently across existing gitignore-mode projects when the set grows. +**Public reachability decides harder than project type.** Any repo whose remotes include a non-cjennings.net host gitignores the tooling set, whatever kind of project it is — the only exception is a team repo that deliberately shares the config, decided explicitly, never by default. And a private remote is not proof of privacy: a server-side =post-receive --mirror= hook republishes invisibly from the client (the 2026-06-30 =.emacs.d= exposure rode exactly that — a cjennings.net remote mirroring to public GitHub). The sweep recognizes both the anchored (=/.ai/=) and unanchored (=.ai/=) ignore styles — an anchored-style project used to be misread as track-mode and silently skipped — and warns when tracked tooling can reach a non-cjennings.net remote. + **Credential-leak concern: gate it on project type, not on the credential itself.** A tracked secret, token, or credentials doc is only a public-leak risk where the repo can reach a public remote — that is, *code projects pushed to public GitHub*, which is exactly why those gitignore =.ai/= and =.claude/=. For *personal / documentation projects* (the =~/projects/= set: elibrary, home, finances, health, philosophy, etc.), the git remote is a private single-user repo on =cjennings.net=, so tracked credentials inside =.ai/= files are fine — that's the design, the project history IS the project. Do NOT raise a leak warning or suggest gitignoring a secret for these. When the question "is this a leak / should we gitignore this secret?" comes up, decide it on *which kind of project and remote* this is, never on the mere presence of a credential in a tracked file. **When to break out documents:** diff --git a/scripts/sweep-gitignore-tooling.sh b/scripts/sweep-gitignore-tooling.sh index 63fc066..f04d3cd 100755 --- a/scripts/sweep-gitignore-tooling.sh +++ b/scripts/sweep-gitignore-tooling.sh @@ -10,13 +10,19 @@ # # For each AI project (a directory with .ai/protocols.org) under the search # roots, if it's a git checkout in gitignore mode (.ai/ already appears in its -# .gitignore), ensure .ai/, .claude/, CLAUDE.md, and AGENTS.md are all ignored. -# Append only the missing lines, so a re-run is a no-op. +# .gitignore, in either the unanchored `.ai/` or anchored `/.ai/` form), ensure +# .ai/, .claude/, CLAUDE.md, and AGENTS.md are all ignored. Append only the +# missing lines, in whichever style the file already uses, so a re-run is a +# no-op. # # Track-mode projects (.ai/ NOT in .gitignore) are skipped by design: they # track their tooling on purpose — team repos sharing config with teammates who # don't run rulesets, or private-remote personal repos where the history IS the -# project. +# project. But a track-mode project whose tracked tooling is reachable from a +# non-cjennings.net remote gets a loud WARN: per convention the tooling set is +# gitignored anywhere the repo can reach a public host, and a server-side +# mirror hook can publish even a "private" remote (the 2026-06-30 .emacs.d +# exposure rode exactly that). # # A line added here only stops *future* commits. If a target path is already # tracked, the ignore has no effect until it's untracked; the sweep warns so @@ -30,6 +36,14 @@ set -euo pipefail IGNORE_SET=('.ai/' '.claude/' 'CLAUDE.md' 'AGENTS.md') +# A pattern counts as present in either the unanchored (`.ai/`) or anchored +# (`/.ai/`) form — both ignore the root-level path; treating them as different +# is what silently skipped anchored-style projects. +has_ignore() { + local pat="$1" gi="$2" + grep -qFx "$pat" "$gi" || grep -qFx "/$pat" "$gi" +} + dry_run=0 roots=() for arg in "$@"; do @@ -72,16 +86,39 @@ for project in "${projects[@]}"; do continue fi - # Gitignore mode iff .ai/ is already ignored. Otherwise track-mode: leave it. - if [ ! -f "$gi" ] || ! grep -qFx '.ai/' "$gi"; then - echo "skip $name — track-mode (.ai/ not gitignored)" + # Gitignore mode iff .ai/ is already ignored (either style). Otherwise + # track-mode: leave the .gitignore alone, but warn when tracked tooling can + # reach a non-cjennings.net remote — a track-mode repo on a public host (or + # behind an invisible server-side mirror) is the exposure the convention + # exists to prevent. + if [ ! -f "$gi" ] || ! has_ignore '.ai/' "$gi"; then + tracked_tooling=() + for pat in "${IGNORE_SET[@]}"; do + path="${pat%/}" + if git -C "$project" ls-files --error-unmatch "$path" >/dev/null 2>&1; then + tracked_tooling+=("$path") + fi + done + public_remote="$(git -C "$project" remote -v 2>/dev/null \ + | awk '{print $2}' | grep -v 'cjennings\.net' | sort -u | head -1 || true)" + if [ "${#tracked_tooling[@]}" -gt 0 ] && [ -n "$public_remote" ]; then + echo "skip $name — track-mode (.ai/ not gitignored)" + echo " WARN $name: tracked tooling (${tracked_tooling[*]}) is publicly reachable via $public_remote — gitignore the set and 'git -C $project rm --cached -r <path>' unless this is a deliberate team-shared config" + else + echo "skip $name — track-mode (.ai/ not gitignored)" + fi skipped=$((skipped + 1)) continue fi + # Append in the style the file already uses: anchored if its .ai/ marker + # line is the anchored form. + prefix="" + grep -qFx '/.ai/' "$gi" && prefix="/" + needed=() for pat in "${IGNORE_SET[@]}"; do - grep -qFx "$pat" "$gi" || needed+=("$pat") + has_ignore "$pat" "$gi" || needed+=("${prefix}${pat}") done if [ "${#needed[@]}" -eq 0 ]; then @@ -103,9 +140,11 @@ for project in "${projects[@]}"; do swept=$((swept + 1)) # Warn on any newly-ignored path that's already tracked — the ignore won't - # untrack it. + # untrack it. Strip the anchored prefix before asking git: the pattern + # `/CLAUDE.md` is the repo-relative path `CLAUDE.md`. for pat in "${needed[@]}"; do - path="${pat%/}" + path="${pat#/}" + path="${path%/}" if git -C "$project" ls-files --error-unmatch "$path" >/dev/null 2>&1; then echo " WARN $name: $path is currently tracked — 'git -C $project rm --cached -r $path' to untrack" fi diff --git a/scripts/tests/sweep-gitignore-tooling.bats b/scripts/tests/sweep-gitignore-tooling.bats index a28087e..6dc46ee 100644 --- a/scripts/tests/sweep-gitignore-tooling.bats +++ b/scripts/tests/sweep-gitignore-tooling.bats @@ -109,3 +109,70 @@ make_project() { [ "$status" -eq 0 ] [[ "$output" == *"not a git checkout"* ]] } + +@test "sweep: anchored /.ai/ is recognized as gitignore-mode, appends anchored" { + make_project anchored $'/.ai/\n' + + run bash "$SWEEP" "$ROOT" + + [ "$status" -eq 0 ] + [[ "$output" != *"anchored — track-mode"* ]] + grep -qFx "/.claude/" "$ROOT/anchored/.gitignore" + grep -qFx "/CLAUDE.md" "$ROOT/anchored/.gitignore" + grep -qFx "/AGENTS.md" "$ROOT/anchored/.gitignore" +} + +@test "sweep: anchored partial project gets only the missing lines" { + make_project anchoredpartial $'/.ai/\n/.claude/\n' + + run bash "$SWEEP" "$ROOT" + + [ "$status" -eq 0 ] + # /.claude/ already present in anchored form — not re-added in either form. + [ "$(grep -cFx '/.claude/' "$ROOT/anchoredpartial/.gitignore")" -eq 1 ] + ! grep -qFx ".claude/" "$ROOT/anchoredpartial/.gitignore" + grep -qFx "/CLAUDE.md" "$ROOT/anchoredpartial/.gitignore" + grep -qFx "/AGENTS.md" "$ROOT/anchoredpartial/.gitignore" +} + +@test "sweep: anchored gitignore-mode is idempotent" { + make_project anchored2 $'/.ai/\n' + bash "$SWEEP" "$ROOT" >/dev/null + + run bash "$SWEEP" "$ROOT" + + [ "$status" -eq 0 ] + [[ "$output" == *"already complete"* ]] + [ "$(grep -cFx '/.claude/' "$ROOT/anchored2/.gitignore")" -eq 1 ] +} + +@test "sweep: track-mode with tracked tooling and a non-cjennings.net remote warns" { + make_project publictrack $'out/\n' + echo "# project rules" > "$ROOT/publictrack/CLAUDE.md" + (cd "$ROOT/publictrack" \ + && git add CLAUDE.md \ + && git -c user.email=t@t -c user.name=t commit -qm seed \ + && git remote add origin git@github.com:someone/publictrack.git) + + run bash "$SWEEP" "$ROOT" + + [ "$status" -eq 0 ] + [[ "$output" == *"WARN"* ]] + [[ "$output" == *"publicly reachable"* ]] + # Still track-mode: nothing written to its .gitignore. + ! grep -qFx ".claude/" "$ROOT/publictrack/.gitignore" +} + +@test "sweep: track-mode with tracked tooling on a cjennings.net remote stays quiet" { + make_project privatetrack $'out/\n' + echo "# project rules" > "$ROOT/privatetrack/CLAUDE.md" + (cd "$ROOT/privatetrack" \ + && git add CLAUDE.md \ + && git -c user.email=t@t -c user.name=t commit -qm seed \ + && git remote add origin git@cjennings.net:privatetrack.git) + + run bash "$SWEEP" "$ROOT" + + [ "$status" -eq 0 ] + [[ "$output" != *"publicly reachable"* ]] +} |
