aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ai/protocols.org2
-rw-r--r--claude-templates/.ai/protocols.org2
-rwxr-xr-xscripts/sweep-gitignore-tooling.sh57
-rw-r--r--scripts/tests/sweep-gitignore-tooling.bats67
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"* ]]
+}