From 3d57552841c364443e3e6750a5e9b31c0ae0efd1 Mon Sep 17 00:00:00 2001 From: Kodflow <133899878+kodflow@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:04:42 +0200 Subject: [PATCH 1/5] feat(gate): refuse what an agent writes, not what a human wrote for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule shipped in #21 refused agent tooling directories outright. That was too wide by exactly the distinction that matters: an agent's configuration is source. `.claude/agents/`, `commands/`, `skills/`, `settings.json`, `.mcp.json`, `.cursorrules` β€” someone authored those, reviewed them, and wants them shared with the next person to clone. The gate was refusing the work. What has no business in a repository is the exhaust: the session log, the chat transcript, the plan file, the lock, the cache, the personal override. Nobody reads it, nobody reviews it, it conflicts on every merge and it carries whatever the session happened to touch. agent-paths.txt now matches that and nothing else. The line is drawn at NAMED RUNTIME DIRECTORIES, never at an extension and never at a keyword. Two measurements on the fleet say why. First, `.devcontainer/images/.claude/agents/routing-table.jsonl` is tracked in 4 repositories and is authored routing configuration, so a rule reading "a .jsonl under an agent directory is a log" would refuse source. Second, `.claude/sessions/.gitkeep` is tracked in 14: the directory ships empty on purpose and the placeholder is what makes it exist, so `sessions/` is not matched at all. A keyword rule fares worse still β€” `docs/cloud/cache-aside.md`, `scripts/session-init.sh` and `docs/security/session-auth.md` are all legitimate source carrying the words a lazy filter would look for. Measured across the same 55 repositories: 20 were refused by the old rule, 1 is refused by this one β€” `kodflow/terraform-provider-n8n`, for a tracked `.claude/settings.local.json`, which is one developer's permissions imposed on everybody who clones. The stub exemptions two repositories added within hours of #21 stop being necessary; their workflows can go back to the central stub. The input names are unchanged on purpose: kodflow/3gpp-mcp and kitsunium/sdk already set `agent_files_allow`, and renaming it would drop their exemption silently. Also isolates the test repositories from the host's global gitignore. This machine's carries `**/.claude/settings.local.json`, so three cases created a file, never tracked it, and passed against an empty tree. --- README.md | 80 +++++++------- action.yml | 25 ++--- scripts/agent-paths.txt | 166 +++++++++++++---------------- scripts/post-commit.sh | 16 +-- tests/run.sh | 224 +++++++++++++++++++++++----------------- 5 files changed, 270 insertions(+), 241 deletions(-) diff --git a/README.md b/README.md index 0ef05aa..73e6c9a 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,15 @@ on GitHub, against commits that already exist. There is nothing to skip. | **AI attribution** | **every commit reachable from the branch** (default) | any commit's message, author or committer carries an attribution: `Co-authored-by:` naming an assistant, a "Generated by/with …" banner, the πŸ€– footer, an `noreply@anthropic.com` identity, "AI-assisted", a `Plan:` footer | | **Format** | the commits this push/PR introduces (merges skipped) | a subject is not `type(scope)!: subject` with a conventional type | | **Secrets** | lines this push/PR *adds*, outside test/fixture paths | a line looks like a credential (AWS key, GitHub token, private-key header, a quoted `password` assignment, …) | -| **Agent artefacts** | **every path tracked in the tree at the head** | an AI agent's tooling configuration is committed: `.claude/`, `.cursor/`, `.cursorrules`, `.windsurf/`, `.codex/`, `.gemini/`, `.aider.*`, `.roo/`, `.cline/`, `.goose/`, `.amazonq/`, `.kiro/`, `.specstory/`, `.mcp.json`, … | +| **Agent artefacts** | **every path tracked in the tree at the head** | an agent's *output* is committed: `.claude/logs/`, `.claude/plans/`, `settings.local.json`, `.aider.chat.history.md`, `.specstory/history/`, session/cache/lock files. An agent's *configuration* is source and never matches | Default attribution patterns match the **shape of an attribution**, not the mention of a vendor. Measured on `devcontainer-template`'s real history, a bare `.claude/` rule matches 25 of the last 60 legitimate commits. Keyword rules exist but are opt-in (`strict: true`). -That caution is about **prose**, and the artefact check is the other side of -it. A commit message naming `.claude/` is ordinary work; the string -`.claude/settings.json` in the tree *is* the thing. Same characters, no -ambiguity to be fair to β€” so paths are a default while keywords are not. +The artefact check applies the same discipline to paths: match the shape of +the artefact, and check it against real history before making it a default. **Deliberately not here: lint, build, test.** Every repository's own CI already runs those on GitHub, so `--no-verify` never bypassed them. @@ -70,44 +68,54 @@ The gate reports and never rewrites; see [Rewriting history](#rewriting-history) Set `history: range` to only check the commits a push/PR introduces. -### Agent artefacts: the tree, and a cheap way out +### Agent artefacts: configuration is source, exhaust is not -The artefact check reads the **tree at the head**, not the range. That is the -whole point of it: the `.claude/` that prompted the rule was merged into a -trunk long before the rule existed, and a check scoped to what a change *adds* -would call every later pull request clean while the directory sat there. The -gate stays red until the files are gone. +An agent's configuration is **source**. `.claude/agents/`, `.claude/commands/`, +`.claude/skills/`, `.mcp.json`, `.cursor/rules/`, `.clinerules` β€” someone +authored those, reviewed them, and wants them shared with the next person to +clone the repository. Refusing them would be refusing the work. -It can afford that scope because gone is cheap here, and this is the one place -the gate refuses something without demanding a rewrite: +What has no business in a repository is the **exhaust**: the session log, the +chat transcript, the plan file, the lock, the cache, the personal override. +Nobody reads it, nobody reviews it, it conflicts on every merge, and it carries +whatever the session happened to touch. + +The line is drawn at **named runtime directories** β€” never at an extension, +never at a keyword. Two measurements on the fleet say why: + +- `.devcontainer/images/.claude/agents/routing-table.jsonl` is tracked in **4** + repositories, and it is authored routing configuration. A rule reading "a + `.jsonl` under an agent directory is a log" would refuse source. +- `.claude/sessions/.gitkeep` is tracked in **14**. The directory is shipped + empty on purpose by the devcontainer image, and the placeholder is what makes + it exist. `sessions/` is therefore not matched at all. + +**Not matched, by decision rather than by omission**: everything above, plus +editor configuration (`.vscode/`, `.idea/`, `.zed/`), `.devcontainer/`, and +markdown instructions (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`). Nothing is +matched *because* it sits under an agent directory β€” the directory is +legitimate; only the listed children are not. A log dropped inside +`.devcontainer/images/.claude/` is still a log, though: no parent grants +immunity, or "put it under `.devcontainer/`" becomes the way around the rule. + +### The tree, and a cheap way out + +The check reads the **tree at the head**, not the range. A check scoped to what +a change *adds* would call every later pull request clean while the artefact sat +in the trunk. The gate stays red until it is gone. + +It can afford that scope because gone is cheap, and this is the one place the +gate refuses something without demanding a rewrite: ```sh -git rm -r --cached .claude && echo '.claude/' >> .gitignore -git commit -m "chore: untrack the agent configuration" +git rm -r --cached .claude/logs && echo '.claude/logs/' >> .gitignore +git commit -m "chore: untrack the session logs" ``` The files stay on your machine; the repository stops carrying them. Nothing walks the ancestry, so that single commit ends it β€” unlike an attribution, which lives in a commit and needs `scripts/rewrite-history.sh`. -**Not matched, by decision rather than by omission.** Editor configuration β€” -`.vscode/`, `.idea/`, `.zed/` β€” is untouched: an editor is not an agent, and a -repository's editor settings long predate all of this. Neither is -`.devcontainer/` itself, which humans and CI read too. Neither are markdown -instructions (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`): 21 of the fleet's 55 -repositories carry one, and whether that prose belongs in a repository is an -editorial call a gate is the wrong place to make. - -Read that claim precisely: nothing is matched *because* it sits under -`.devcontainer/`. An agent directory nested there is still an agent directory β€” -the fleet's own template ships one at `.devcontainer/images/.claude/` β€” and -exempting a parent would make "put it under `.devcontainer/`" the way around -every rule in the file. - -A repository whose purpose *is* to distribute this configuration exempts the -exact paths it ships with `agent_files_allow`, rather than by weakening the -list everyone else runs. - ## Install `.github/workflows/post-commit.yml` in the target repo (`stub/post-commit.yml` here): @@ -144,8 +152,8 @@ future fix. Don't pin it, don't copy the logic in. | `strict` | `false` | also apply `scripts/patterns-strict.txt` (vendor keywords, `.claude/` paths) | | `format` | `true` | conventional-commit subjects | | `secrets` | `true` | credential scan on added lines | -| `agent_files` | `true` | refuse an agent's tooling configuration tracked in the tree | -| `agent_files_allow` | *(empty)* | paths exempt from that check, space or comma separated. A bare entry exempts the whole subtree: `.claude` covers `.claude/settings.json` | +| `agent_files` | `true` | refuse an agent's session exhaust tracked in the tree; its configuration is never matched | +| `agent_files_allow` | *(empty)* | paths exempt from that check, space or comma separated. A bare entry exempts the whole subtree: `.claude/logs` covers everything under it | | `token` | `github.token` | checkout token | ## Making it mandatory β€” and irremovable @@ -230,7 +238,7 @@ action.yml the action (composite): checkout β†’ resolve β†’ g scripts/post-commit.sh the gate scripts/patterns.txt default forbidden patterns (attribution-shaped) scripts/patterns-strict.txt opt-in keyword patterns -scripts/agent-paths.txt agent tooling paths (.claude/, .cursor/, …); editor config kept +scripts/agent-paths.txt agent RUNTIME paths (logs, transcripts, caches); config kept scripts/enforce.sh fleet: stub PR + ruleset, idempotent scripts/rewrite-history.sh history scrub (messages + identities), dry-run by default stub/post-commit.yml the file installed in each repo diff --git a/action.yml b/action.yml index 6d721bc..aa2f4b0 100644 --- a/action.yml +++ b/action.yml @@ -5,8 +5,8 @@ name: post-commit description: >- Mandatory merge gate: every commit authored by an allowed account, no AI - attribution anywhere in history, no agent configuration tracked in the tree, - conventional commit subjects, no credentials added. Logic lives in + attribution anywhere in history, no agent session exhaust tracked in the + tree, conventional commit subjects, no credentials added. Logic lives in kodflow/post-commit β€” callers pin @main so a fix there is live everywhere on the next run. branding: @@ -38,19 +38,20 @@ inputs: default: 'true' agent_files: description: >- - Refuse an AI agent's tooling configuration tracked in the repository β€” - .claude/, .cursor/, .windsurf/, .aider.*, .codex/, … (full list: - scripts/agent-paths.txt). The tree at the head is what is read, not the - range, so the check stays red until the files are gone; one `git rm - --cached` clears it, no history rewrite. Editor configuration (.vscode/, - .idea/), .devcontainer/ and markdown instructions (CLAUDE.md, AGENTS.md) - are never matched. + Refuse what an AI agent WRITES when it is tracked in the repository β€” + .claude/logs/, .claude/plans/, settings.local.json, .aider.chat.history.md, + .specstory/history/, session and cache directories (full list: + scripts/agent-paths.txt). An agent's CONFIGURATION is source and is never + matched: .claude/agents/, commands/, skills/, settings.json, .mcp.json, + .cursorrules and the rest. The tree at the head is what is read, not the + range, so the check stays red until the exhaust is gone; one `git rm + --cached` clears it, no history rewrite. default: 'true' agent_files_allow: description: >- - Space/comma separated paths exempt from the agent-artefact check, for a - repository whose purpose is to distribute that configuration. A bare - entry exempts the whole subtree: `.claude` covers `.claude/settings.json`. + Space/comma separated paths exempt from the agent-artefact check. A bare + entry exempts the whole subtree: `.claude/logs` covers everything under + it. Rarely needed now that configuration is allowed by default. default: '' token: description: Token used to check out the calling repository. diff --git a/scripts/agent-paths.txt b/scripts/agent-paths.txt index 4219491..2e2a5c2 100644 --- a/scripts/agent-paths.txt +++ b/scripts/agent-paths.txt @@ -4,102 +4,84 @@ # Single source of truth for the fleet: a rule added here is live in every # repo pinned to kodflow/post-commit@main on its next run. # -# DESIGN β€” a tracked PATH, not a mention. `.claude/` had to stay opt-in in -# patterns.txt for a precise reason: a commit MESSAGE naming it is ordinary -# work ("fix: update .claude/scripts/…"), and 25 of devcontainer-template's -# last 60 legitimate subjects match. A tracked path is the opposite case. The -# string `.claude/settings.json` in the tree IS the artefact; there is nothing -# to misread, no prose to be fair to. Same characters, no false-positive -# problem β€” so this file is a default while patterns-strict.txt is not. +# WHAT THIS REFUSES β€” what an agent WRITES, never what a human wrote for it. # -# The remedy differs too, and that is why the scope can afford to be the whole -# tree. An attribution lives in a commit and only a history rewrite removes it. -# A tracked file is removed by removing it: +# An agent's configuration is source. `.claude/agents/`, `.claude/commands/`, +# `.claude/skills/`, `.mcp.json`, `.cursor/rules/`, `.clinerules` β€” someone +# authored those, reviewed them, and wants them shared with the next person to +# clone the repository. Refusing them would be refusing the work. # -# git rm -r --cached .claude && echo '.claude/' >> .gitignore && commit +# What has no business in a repository is the exhaust: the session log, the +# chat transcript, the plan file, the lock, the cache, the personal override. +# Nobody reads it, nobody reviews it, it conflicts on every merge and it leaks +# whatever the session happened to touch. That is what these patterns match. # -# The check reads the tree at the head, never the ancestry, so that one commit -# clears it for good. +# The line is therefore drawn at NAMED RUNTIME DIRECTORIES, never at an +# extension and never at a keyword. Two measurements on the fleet say why: # -# SCOPE β€” an agent's tooling configuration. Deliberately NOT matched, and by -# decision rather than by omission: +# Β· `.devcontainer/images/.claude/agents/routing-table.jsonl` is tracked in +# 4 repositories. It is authored routing configuration. A rule reading +# "a .jsonl under an agent directory is a log" would refuse source. +# Β· `.claude/sessions/.gitkeep` is tracked in 14. The directory is shipped +# empty on purpose by the devcontainer image, and the placeholder is what +# makes it exist. `sessions/` is therefore NOT matched β€” should a real +# session file ever land there, name it here rather than the directory. # -# Β· .vscode/ .idea/ .zed/ .fleet/ .helix/ β€” editor configuration. An editor -# is not an agent, and a repository's editor settings long predate all of -# this. Whatever else changes here, these stay out. -# Β· .devcontainer/ β€” the environment definition. Humans and CI read it too. -# Note the exact claim: nothing is matched BECAUSE it sits under -# .devcontainer/. An agent directory nested there is still an agent -# directory β€” the fleet's own template ships one at -# .devcontainer/images/.claude/ β€” and exempting a parent would make -# "put it under .devcontainer/" the way around every rule in this file. -# Β· CLAUDE.md AGENTS.md GEMINI.md .github/copilot-instructions.md β€” markdown -# instructions read as project documentation, and 21 fleet repositories -# carry one. Whether that prose belongs in a repository is an editorial -# call; a gate is the wrong place to make it. +# Same discipline as patterns.txt, one level down: match the shape of the +# artefact, not the mention of a vendor, and check it against real history +# before making it a default. # -# A repository whose purpose IS to distribute this configuration β€” -# devcontainer-template β€” exempts itself with the `agent_files_allow` input -# rather than by weakening the list here. +# DELIBERATELY NOT MATCHED, and by decision rather than by omission: +# +# Β· `.claude/agents/` `commands/` `skills/` `docs/` `scripts/` `templates/` +# `workflows/` `settings.json` `.claude.json` `features.json` β€” authored. +# Β· `.mcp.json`, `.cursor/`, `.cursorrules`, `.windsurfrules`, `.roomodes`, +# `.clinerules`, `.aider.conf.yml`, `.codex/`, `.gemini/`, `.kiro/` β€” +# configuration. An agent's config is as legitimate as an editor's. +# Β· `CLAUDE.md` `AGENTS.md` `GEMINI.md` β€” project documentation. +# Β· `.vscode/` `.idea/` `.zed/` `.devcontainer/` β€” not agent files at all. +# +# Nothing is matched BECAUSE it sits under an agent directory. The directory +# is legitimate; only these specific children are not. +# +# The remedy is unchanged and cheap β€” this check reads the tree at the head, +# never the ancestry, so one commit ends it: +# +# git rm -r --cached .claude/logs && echo '.claude/logs/' >> .gitignore -# --- Claude Code ------------------------------------------------------------ -(^|/)\.claude/ -(^|/)\.claude\.json$ -(^|/)\.claudeignore$ -# The project-level MCP server list: agent tooling by definition. Bare -# `mcp.json` is NOT matched β€” it is the ordinary config file name of every -# repository that implements an MCP server, several of which are ours. -(^|/)\.mcp\.json$ -# --- Cursor ----------------------------------------------------------------- -(^|/)\.cursor/ -(^|/)\.cursorrules$ -(^|/)\.cursorignore$ -(^|/)\.cursorindexingignore$ -# --- OpenAI Codex ----------------------------------------------------------- -(^|/)\.codex/ -# --- GitHub Copilot --------------------------------------------------------- -(^|/)\.copilot/ -# --- Gemini CLI / Code Assist ----------------------------------------------- -(^|/)\.gemini/ -(^|/)\.aiexclude$ -# --- Windsurf / Codeium ----------------------------------------------------- -(^|/)\.windsurf/ -(^|/)\.windsurfrules$ -(^|/)\.codeium/ -(^|/)\.codeiumignore$ -# --- aider ------------------------------------------------------------------ -# One pattern for the lot: aider prefixes every dropping it leaves behind β€” -# .aider.chat.history.md, .aider.input.history, .aider.tags.cache.v3/, -# .aider.conf.yml β€” with the same string. -(^|/)\.aider[.-] -(^|/)\.aiderignore$ -# --- Cline / Roo Code / Kilo Code ------------------------------------------- -(^|/)\.cline/ -# .clinerules is a file in older versions and a directory in newer ones. -(^|/)\.clinerules(/|$) -(^|/)\.roo/ -(^|/)\.roomodes$ -(^|/)\.kilocode/ -# --- Continue --------------------------------------------------------------- -(^|/)\.continue/ -# --- Goose ------------------------------------------------------------------ -(^|/)\.goose/ -(^|/)\.goosehints$ -# --- opencode / Crush / Trae / Junie / Augment ------------------------------ -(^|/)\.opencode/ -(^|/)\.crush/ -(^|/)\.trae/ -(^|/)\.junie/ -(^|/)\.augment/ -(^|/)\.augmentignore$ -# --- Amazon Q Developer / Kiro ---------------------------------------------- -(^|/)\.amazonq/ -(^|/)\.kiro/ -# --- Qodo / SpecStory / Devin / Tabnine / Plandex --------------------------- -(^|/)\.qodo/ -(^|/)\.specstory/ -(^|/)\.devin/ -(^|/)\.tabnine/ -(^|/)\.plandex/ -# --- JetBrains AI ----------------------------------------------------------- -(^|/)\.aiignore$ +# --- Claude Code: what the tool writes into a project ------------------------ +# Observed on this fleet's own machines: logs/ and plans/ appear in every +# working copy, scheduled_tasks.lock in the ones running scheduled work. The +# rest are the user-level directories, refused here too because a stray copy +# of one in a repository is the same exhaust. +(^|/)\.claude/(logs|plans|todos|shell-snapshots|statsig|paste-cache|session-env|backups|downloads|projects|ide|chrome)/ +(^|/)\.claude/history\.jsonl$ +(^|/)\.claude/[^/]*\.lock$ +# A personal machine's overrides, which Claude Code itself keeps out of git. +# Committing one imposes one developer's permissions on everybody. +(^|/)\.claude/settings\.local\.json$ +# Timestamped backups the tool leaves beside a file it rewrote. +(^|/)\.claude/[^/]*\.bak([-.][^/]*)?$ +# --- aider ------------------------------------------------------------------- +# The transcripts and the tag cache. `.aider.conf.yml` is configuration and is +# deliberately absent from this list. +(^|/)\.aider\.chat\.history\.md$ +(^|/)\.aider\.input\.history$ +(^|/)\.aider\.llm\.history$ +(^|/)\.aider\.tags\.cache +# --- SpecStory --------------------------------------------------------------- +# Recording the chat into the repository is the product's whole purpose, which +# makes it exactly the thing this rule exists to keep out. +(^|/)\.specstory/history/ +# --- Continue ---------------------------------------------------------------- +(^|/)\.continue/(sessions|index|dev_data)/ +# --- Goose ------------------------------------------------------------------- +(^|/)\.goose/(sessions|logs)/ +# --- Amazon Q Developer / Codeium / Qodo ------------------------------------- +(^|/)\.amazonq/cache/ +(^|/)\.codeium/cache/ +(^|/)\.qodo/(cache|history)/ +# --- Cursor ------------------------------------------------------------------ +# Cursor keeps its chats out of the repository; only the local override lands +# here, and it carries one machine's settings just like Claude Code's. +(^|/)\.cursor/[^/]*\.local\.json$ diff --git a/scripts/post-commit.sh b/scripts/post-commit.sh index 947ee16..f9b17eb 100755 --- a/scripts/post-commit.sh +++ b/scripts/post-commit.sh @@ -454,9 +454,9 @@ esc_prop() { if [ "$AGENT_N" -gt 0 ]; then echo "### ❌ Agent artefacts tracked β€” $AGENT_N file(s)" echo "" - echo "An AI agent's tooling configuration does not belong in a repository." - echo "This is the same rejection policy the attribution rules apply to commit" - echo "messages, applied to what a change leaves on disk." + echo "These are files an agent WROTE β€” a session log, a transcript, a plan," + echo "a lock, a cache, a personal override. Nobody reviews them, they conflict" + echo "on every merge, and they carry whatever the session happened to touch." echo "" shown=0 for root in ${AGENT_ROOT_ORDER[@]+"${AGENT_ROOT_ORDER[@]}"}; do @@ -478,11 +478,11 @@ esc_prop() { echo "> **No history rewrite is needed**: this check reads the tree at the head," echo "> not the ancestry, so one commit clears it." echo ">" - echo "> Editor configuration (\`.vscode/\`, \`.idea/\`), \`.devcontainer/\` itself and" - echo "> markdown instructions (\`CLAUDE.md\`, \`AGENTS.md\`) are never matched β€” but an" - echo "> agent directory nested inside one of them still is." - echo "> A repository that exists to distribute this configuration exempts the" - echo "> exact paths it ships with the \`agent_files_allow\` input." + echo "> The agent's **configuration is source and is never matched**:" + echo "> \`.claude/agents/\`, \`commands/\`, \`skills/\`, \`settings.json\`, \`.mcp.json\`," + echo "> \`.cursorrules\` β€” and editor config and \`CLAUDE.md\` alongside them." + echo "> Only the listed runtime directories and filenames are refused, wherever" + echo "> they sit. \`agent_files_allow\` exempts a path if one is genuinely wanted." echo "" fi } > "$REPORT_BODY" diff --git a/tests/run.sh b/tests/run.sh index 9af3da7..e286a08 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -19,6 +19,12 @@ mkrepo() { git -C "$d" config user.name "Dev" git -C "$d" config commit.gpgsign false git -C "$d" config core.hooksPath /dev/null # immune to the host's global hooks + # …and to its global gitignore. This machine's carries + # `**/.claude/settings.local.json`, so three cases silently tested nothing: + # the file was created, never tracked, and the gate correctly found an empty + # tree. A test repo must be built from what the case writes, not from what + # the developer running it happens to exclude. + git -C "$d" config core.excludesFile /dev/null echo seed > "$d/seed.txt" git -C "$d" add -A git -C "$d" commit -qm "chore: seed" @@ -343,81 +349,124 @@ d=$(mkrepo); mkdir -p "$d/tests"; echo 'AKIAIOSFODNN7EXAMPLE' > "$d/tests/fixtur git -C "$d" add -A; git -C "$d" commit -qm "chore: config" check "a real path still fails even with a fixture alongside" 1 "$d" -echo "== agent artefacts ==" -# The tree, not the range. Every case below commits with a conventional -# subject and an allowed identity so that exit 1 can only mean this check. -d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: add agent config" -check "a tracked .claude/ is refused" 1 "$d" +echo "== agent artefacts: configuration is source, exhaust is not ==" +# The rule refuses what an agent WRITES, not what a human wrote for it. These +# first cases are the ones a repository is entitled to carry. +d=$(mkrepo); mkdir -p "$d/.claude/agents" "$d/.claude/commands" "$d/.claude/skills" +echo '{}' > "$d/.claude/settings.json"; echo '# a' > "$d/.claude/agents/reviewer.md" +echo '# c' > "$d/.claude/commands/ship.md"; echo '# s' > "$d/.claude/skills/deploy.md" +echo '{}' > "$d/.mcp.json" +git -C "$d" add -A; git -C "$d" commit -qm "chore: agent configuration" +check "settings, agents, commands, skills and .mcp.json are source" 0 "$d" + +# Both of these are real fleet content, and both would fall to a lazier rule: +# routing-table.jsonl to "a .jsonl under an agent directory is a log" (tracked +# in 4 repos), .gitkeep to matching `sessions/` (tracked in 14). +d=$(mkrepo); mkdir -p "$d/.claude/agents" "$d/.claude/sessions" +echo '{"a":1}' > "$d/.claude/agents/routing-table.jsonl" +: > "$d/.claude/sessions/.gitkeep" +git -C "$d" add -A; git -C "$d" commit -qm "chore: authored jsonl and an empty session dir" +check "an authored .jsonl and a sessions placeholder are kept" 0 "$d" + +d=$(mkrepo); mkdir -p "$d/.cursor/rules" +echo 'be nice' > "$d/.cursorrules"; echo '---' > "$d/.cursor/rules/style.mdc" +echo 'model: gpt' > "$d/.aider.conf.yml"; echo '{}' > "$d/.roomodes" +git -C "$d" add -A; git -C "$d" commit -qm "chore: other tools' configuration" +check "every other tool's configuration is kept too" 0 "$d" -d=$(mkrepo); mkdir -p "$d/.vscode"; echo '{}' > "$d/.vscode/settings.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: editor settings" -check ".vscode/ is editor config, not an agent" 0 "$d" +d=$(mkrepo); echo '# instructions' > "$d/CLAUDE.md"; echo '# agents' > "$d/AGENTS.md" +mkdir -p "$d/.vscode" "$d/.idea" "$d/.devcontainer" +echo '{}' > "$d/.vscode/settings.json"; echo '' > "$d/.idea/workspace.xml" +echo '{}' > "$d/.devcontainer/devcontainer.json" +git -C "$d" add -A; git -C "$d" commit -qm "docs: instructions and editor config" +check "documentation and editor config are untouched" 0 "$d" -d=$(mkrepo); mkdir -p "$d/.idea" "$d/.devcontainer" -echo '' > "$d/.idea/workspace.xml"; echo '{}' > "$d/.devcontainer/devcontainer.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: editor and environment" -check ".idea/ and .devcontainer/ are kept" 0 "$d" +echo "== agent artefacts: the exhaust (expect 1) ==" +d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{"e":1}' > "$d/.claude/logs/session.jsonl" +git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log" +check "a session log is refused" 1 "$d" -# The scope decision of #21: markdown instructions read as project -# documentation and 21 fleet repos carry one, so they are deliberately out. -d=$(mkrepo); echo '# instructions' > "$d/CLAUDE.md"; echo '# agents' > "$d/AGENTS.md" -git -C "$d" add -A; git -C "$d" commit -qm "docs: project instructions" -check "CLAUDE.md and AGENTS.md are tolerated as documentation" 0 "$d" +d=$(mkrepo); mkdir -p "$d/.claude/plans"; echo '# plan' > "$d/.claude/plans/thing.md" +git -C "$d" add -A; git -C "$d" commit -qm "chore: a plan file" +check "a plan file is refused" 1 "$d" + +# One developer's permissions, imposed on everybody who clones. +d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.local.json" +git -C "$d" add -A; git -C "$d" commit -qm "chore: local overrides" +check "a personal settings.local.json is refused" 1 "$d" -d=$(mkrepo); echo 'be nice' > "$d/.cursorrules" -git -C "$d" add -A; git -C "$d" commit -qm "chore: cursor rules" -check "a dotfile agent config is refused too" 1 "$d" +d=$(mkrepo); mkdir -p "$d/.claude"; : > "$d/.claude/scheduled_tasks.lock" +echo '{}' > "$d/.claude/history.jsonl" +git -C "$d" add -A; git -C "$d" commit -qm "chore: a lock and a history" +check "a lock file and history.jsonl are refused" 1 "$d" d=$(mkrepo); echo 'chat' > "$d/.aider.chat.history.md" -git -C "$d" add -A; git -C "$d" commit -qm "chore: leftovers" -check "aider drops its history with a known prefix" 1 "$d" - -d=$(mkrepo); mkdir -p "$d/packages/app/.claude"; echo '{}' > "$d/packages/app/.claude/x.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: nested agent config" -check "a nested .claude/ is found, not just the root one" 1 "$d" - -# The whole point of reading the tree: the artefact arrives in an ancestor and -# the range that adds it is long merged. A range-scoped check would call this -# clean, which is exactly how the directory that prompted the rule survived. -d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config" +git -C "$d" add -A; git -C "$d" commit -qm "chore: aider transcript" +check "aider's transcript is refused while its conf is not" 1 "$d" + +d=$(mkrepo); mkdir -p "$d/.specstory/history"; echo 'chat' > "$d/.specstory/history/2026-01-01.md" +git -C "$d" add -A; git -C "$d" commit -qm "chore: recorded chat" +check "a recorded chat transcript is refused" 1 "$d" + +d=$(mkrepo); mkdir -p "$d/.continue/sessions" "$d/.goose/logs" +echo '{}' > "$d/.continue/sessions/a.json"; echo 'x' > "$d/.goose/logs/a.log" +git -C "$d" add -A; git -C "$d" commit -qm "chore: other tools' exhaust" +check "other tools' session and log directories are refused" 1 "$d" + +# The devcontainer image carries a whole .claude/ tree; a log dropped inside it +# is still a log, and nothing is spared for sitting under .devcontainer/. +d=$(mkrepo); mkdir -p "$d/.devcontainer/images/.claude/logs" "$d/.devcontainer/images/.claude/agents" +echo '# a' > "$d/.devcontainer/images/.claude/agents/x.md" +echo '{}' > "$d/.devcontainer/images/.claude/logs/session.jsonl" +git -C "$d" add -A; git -C "$d" commit -qm "chore: image payload with a stray log" +check "a log nested in the devcontainer payload is still refused" 1 "$d" + +echo "== agent artefacts: scope, exemptions and reporting ==" +# The tree, not the range: the artefact arrives in an ancestor and the change +# that added it is long merged. A range-scoped check would call this clean. +d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/a.jsonl" +git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log" commit_msg "$d" "feat: unrelated later work" check "an artefact merged earlier still fails a later clean change" 1 "$d" PC_HISTORY=range -# …and removing it is enough. No history rewrite, unlike an attribution. -d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config" -git -C "$d" rm -qr .claude; git -C "$d" commit -qm "chore: drop the agent config" +d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/a.jsonl" +git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log" +git -C "$d" rm -qr .claude/logs; git -C "$d" commit -qm "chore: untrack the logs" check "removing it in one commit clears the check" 0 "$d" -d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config" +d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/a.jsonl" +git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log" check "the check is switchable" 0 "$d" PC_AGENT_FILES=false -# A repository whose purpose is to distribute this config exempts its paths. -d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config" -check "a bare allow entry exempts the whole subtree" 0 "$d" PC_AGENT_ALLOW=.claude +d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/a.jsonl" +git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log" +check "a bare allow entry exempts the whole subtree" 0 "$d" PC_AGENT_ALLOW=.claude/logs -d=$(mkrepo); mkdir -p "$d/.claude" "$d/.cursor" -echo '{}' > "$d/.claude/settings.json"; echo '{}' > "$d/.cursor/rules.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: two agents" -check "an allow entry exempts only what it names" 1 "$d" PC_AGENT_ALLOW=".claude/*" +# `.claude/logs/*` must cover hidden files too. An unquoted expansion would +# glob it against the working tree first, and filename globbing skips leading +# dots β€” so this passed for a.jsonl and quietly failed for .hidden.jsonl. +d=$(mkrepo); mkdir -p "$d/.claude/logs" +echo '{}' > "$d/.claude/logs/a.jsonl"; echo '{}' > "$d/.claude/logs/.hidden.jsonl" +git -C "$d" add -A; git -C "$d" commit -qm "chore: logs including a hidden one" +check "a glob allow entry covers hidden files too" 0 "$d" PC_AGENT_ALLOW=".claude/logs/*" + +d=$(mkrepo); mkdir -p "$d/.claude/logs" "$d/.claude/plans" +echo '{}' > "$d/.claude/logs/a.jsonl"; echo '#' > "$d/.claude/plans/p.md" +git -C "$d" add -A; git -C "$d" commit -qm "chore: logs and plans" +check "an allow entry exempts only what it names" 1 "$d" PC_AGENT_ALLOW=".claude/logs/*" # git C-quotes a path holding a non-ASCII or control character unless asked for # NUL-delimited output, and the quote it adds lands exactly where `(^|/)` and -# `$` need a path boundary. Before the fix these three were reported clean β€” -# naming every file in `.claude/` with an accent evaded the whole rule. -d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/naΓ―ve.md" -git -C "$d" add -A; git -C "$d" commit -qm "chore: accented name inside the artefact" +# `$` need a path boundary. Before that fix these were reported clean. +d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/naΓ―ve.jsonl" +git -C "$d" add -A; git -C "$d" commit -qm "chore: accented log name" check "a non-ASCII file name does not evade the scan" 1 "$d" -d=$(mkrepo); mkdir -p "$d/dΓ©jΓ "; echo '{}' > "$d/dΓ©jΓ /.mcp.json" +d=$(mkrepo); mkdir -p "$d/dΓ©jΓ /.claude"; echo '{}' > "$d/dΓ©jΓ /.claude/settings.local.json" git -C "$d" add -A; git -C "$d" commit -qm "chore: anchored artefact below a non-ASCII parent" check "an end-anchored pattern still matches below a non-ASCII parent" 1 "$d" -d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/$(printf 'two\nlines').md" +d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/$(printf 'two\nlines').jsonl" git -C "$d" add -A; git -C "$d" commit -qm "chore: newline in the file name" check "a newline in the file name does not split a record" 1 "$d" @@ -425,9 +474,9 @@ check "a newline in the file name does not split a record" 1 "$d" # array subscript, a [[ ]] operand and an annotation. `declare -A` is what makes # the subscript a string rather than an arithmetic expression; this is here so # that dropping the -A, or reaching for an indexed array, fails loudly. -d=$(mkrepo); mkdir -p "$d/a\$(touch PWNED).d" "$d/c];touch PWNED3;x[.d" -printf 'x' > "$d/a\$(touch PWNED).d/.mcp.json" -printf 'x' > "$d/c];touch PWNED3;x[.d/.mcp.json" +d=$(mkrepo); mkdir -p "$d/a\$(touch PWNED).d/.claude" "$d/c];touch PWNED3;x[.d/.claude" +printf 'x' > "$d/a\$(touch PWNED).d/.claude/settings.local.json" +printf 'x' > "$d/c];touch PWNED3;x[.d/.claude/settings.local.json" git -C "$d" add -A; git -C "$d" commit -qm "chore: hostile parent names" ( cd "$d" && env GITHUB_STEP_SUMMARY=/dev/null bash "$GATE" HEAD HEAD~1..HEAD ) >/dev/null 2>&1 rc=$? @@ -443,25 +492,15 @@ else fi rm -rf "$d" -# `.claude/*` must exempt what is under `.claude/`, hidden files included. An -# unquoted expansion would glob it against the working tree first, and bash's -# filename globbing skips leading dots β€” so this passed for settings.json and -# quietly failed for .mcp.json next to it. -d=$(mkrepo); mkdir -p "$d/.claude" -echo '{}' > "$d/.claude/settings.json"; echo '{}' > "$d/.claude/.mcp.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config with a hidden file" -check "a glob allow entry covers hidden files too" 0 "$d" PC_AGENT_ALLOW=".claude/*" - -# The root is found with the pattern list, so the pattern list has to reach awk -# intact. On an awk that strips the escape from a -v value, `\.` becomes a bare -# `.`, `aclaude/` matches `(^|/).claude/`, and the verdict names an ordinary -# directory as the thing to delete. -d=$(mkrepo); mkdir -p "$d/aclaude/.claude" -echo '{}' > "$d/aclaude/.claude/settings.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config below a lookalike" +# The root is found with the pattern list, so the pattern list has to reach the +# matcher intact. With the escape stripped, `\.` becomes a bare `.`, `aclaude/` +# matches `(^|/).claude/logs/`, and the verdict names an ordinary directory. +d=$(mkrepo); mkdir -p "$d/aclaude/.claude/logs" +echo '{}' > "$d/aclaude/.claude/logs/a.jsonl" +git -C "$d" add -A; git -C "$d" commit -qm "chore: log below a lookalike" r="$(mktemp)" ( cd "$d" && env GITHUB_STEP_SUMMARY=/dev/null PC_REPORT="$r" bash "$GATE" HEAD HEAD~1..HEAD ) >/dev/null 2>&1 -if grep -qF '`aclaude/.claude/`' "$r" && ! grep -qF '`aclaude/`' "$r"; then +if grep -qF '`aclaude/.claude/logs/`' "$r" && ! grep -qF '`aclaude/`' "$r"; then PASS=$((PASS+1)); printf ' ok %s\n' "a directory whose name merely ends in the pattern is not the root" else FAIL=$((FAIL+1)); printf ' FAIL %s\n%s\n' "a directory whose name merely ends in the pattern is not the root" "$(sed 's/^/ /' "$r")" @@ -469,26 +508,25 @@ fi rm -rf "$d" "$r" # The verdict names the artefact root, and getting that root wrong is worse -# than not collapsing at all: the fleet's devcontainer ships its agent config -# at .devcontainer/images/.claude/, and naming `.devcontainer/` as the thing to -# delete would point at 400 files the gate has no quarrel with. -d=$(mkrepo); mkdir -p "$d/.devcontainer/images/.claude" -echo '{}' > "$d/.devcontainer/images/.claude/settings.json" -echo '{}' > "$d/.devcontainer/devcontainer.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: devcontainer with agent config" +# than not collapsing at all: the surrounding `.claude/` is legitimate here, so +# naming it as the thing to delete would point at the agent's own source. +d=$(mkrepo); mkdir -p "$d/.claude/logs" "$d/.claude/agents" +echo '# a' > "$d/.claude/agents/x.md" +for i in 1 2 3; do echo '{}' > "$d/.claude/logs/a$i.jsonl"; done +git -C "$d" add -A; git -C "$d" commit -qm "chore: agent source beside its logs" r="$(mktemp)" ( cd "$d" && env GITHUB_STEP_SUMMARY=/dev/null PC_REPORT="$r" bash "$GATE" HEAD HEAD~1..HEAD ) >/dev/null 2>&1 -if grep -qF '`.devcontainer/images/.claude/`' "$r" && ! grep -qF '`.devcontainer/` β€”' "$r"; then - PASS=$((PASS+1)); printf ' ok %s\n' "the reported root is the artefact, not the .devcontainer around it" +if grep -qF '`.claude/logs/` β€” 3 file(s)' "$r" && ! grep -qF '`.claude/`' "$r"; then + PASS=$((PASS+1)); printf ' ok %s\n' "the reported root is the log directory, not the .claude around it" else - FAIL=$((FAIL+1)); printf ' FAIL %s\n%s\n' "the reported root is the artefact, not the .devcontainer around it" "$(sed 's/^/ /' "$r")" + FAIL=$((FAIL+1)); printf ' FAIL %s\n%s\n' "the reported root is the log directory, not the .claude around it" "$(sed 's/^/ /' "$r")" fi rm -rf "$d" "$r" # The whole-repository scope (push, manual runs) is not a tree-ish; the check # has to fall back to the checked-out head rather than fail the job. -d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json" -git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config" +d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/a.jsonl" +git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log" out="$(cd "$d" && env GITHUB_STEP_SUMMARY=/dev/null bash "$GATE" --branches 2>&1)"; rc=$? if [ "$rc" -eq 1 ] && printf '%s' "$out" | grep -q 'agent artefact'; then PASS=$((PASS+1)); printf ' ok %s\n' "--branches scans the checked-out tree" @@ -498,24 +536,24 @@ fi rm -rf "$d" # rewrite-history.sh keeps the artefact check off on both of its gate calls: it -# rewrites messages and identities and never touches the tree, so a tracked -# `.claude/` would make --execute refuse to push a correct rewrite. Running the -# script itself needs a network mirror and git-filter-repo; what is guarded -# here is the pairing that can silently come undone. +# rewrites messages and identities and never touches the tree, so a tracked log +# would make --execute refuse to push a correct rewrite. Running the script +# itself needs a network mirror and git-filter-repo; what is guarded here is the +# pairing that can silently come undone. +# # Counted on the invoking lines themselves, not on the file: the comment that # explains the pairing names the variable too, and matching that would make the -# guard pass on the explanation alone. +# guard pass on the explanation alone. Two, not "at least one": the before scan +# feeds the counters the report prints, the after scan is what --execute reads. calls="$(grep -c 'post-commit.sh" --branches' "$ROOT/scripts/rewrite-history.sh")" guarded="$(grep 'post-commit.sh" --branches' "$ROOT/scripts/rewrite-history.sh" | grep -c 'PC_AGENT_FILES=false')" -# Two, not "at least one": the before scan feeds the tainted/identity counters -# the report prints, and the after scan is what --execute consults before force -# pushing. Losing either is a silent regression that `-gt 0` would wave through. if [ "$calls" -eq 2 ] && [ "$guarded" -eq 2 ]; then PASS=$((PASS+1)); printf ' ok %s\n' "every rewrite-history gate call disables the artefact check" else FAIL=$((FAIL+1)); printf ' FAIL %s (want 2 call(s) all guarded, got %s call(s), %s guarded)\n' "every rewrite-history gate call disables the artefact check" "$calls" "$guarded" fi + echo "== usage errors (expect 2) ==" d=$(mkrepo) out="$(cd "$d" && bash "$GATE" 2>&1)"; rc=$? From b196b3c98a85f68a8e278109415d6678b0ab6f1d Mon Sep 17 00:00:00 2001 From: Kodflow <133899878+kodflow@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:08:33 +0200 Subject: [PATCH 2/5] fix(gate): align the script's own overview with what the rule now refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two comment blocks still documented the policy this branch removes: the file header's summary of check 6, and the section comment above the check itself. Both said the gate refuses an agent's tooling configuration, which is now precisely what it does not do. A maintainer reading the overview would have taken the wrong contract from the primary place to look. Also adds three patterns found while auditing what a `.claude/` actually accumulates. `.claude/.credentials.json` is the OAuth token store, and the secrets check does not save you from it: that check reads only the lines a push ADDS, so a credentials file committed once is never looked at again, and a `sk-ant-oat01-…` token carries hyphens where its `sk-[a-zA-Z0-9]` pattern expects none. `policy-limits.json` and `remote-settings.json` are account state the server pushes down β€” nobody wrote them and they mean nothing in another checkout. Fleet measurement unchanged: 1 repository of 55 is refused. --- README.md | 9 ++++++++- scripts/agent-paths.txt | 8 ++++++++ scripts/post-commit.sh | 31 +++++++++++++++++++------------ tests/run.sh | 12 ++++++++++++ 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 73e6c9a..5fe187f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ on GitHub, against commits that already exist. There is nothing to skip. | **AI attribution** | **every commit reachable from the branch** (default) | any commit's message, author or committer carries an attribution: `Co-authored-by:` naming an assistant, a "Generated by/with …" banner, the πŸ€– footer, an `noreply@anthropic.com` identity, "AI-assisted", a `Plan:` footer | | **Format** | the commits this push/PR introduces (merges skipped) | a subject is not `type(scope)!: subject` with a conventional type | | **Secrets** | lines this push/PR *adds*, outside test/fixture paths | a line looks like a credential (AWS key, GitHub token, private-key header, a quoted `password` assignment, …) | -| **Agent artefacts** | **every path tracked in the tree at the head** | an agent's *output* is committed: `.claude/logs/`, `.claude/plans/`, `settings.local.json`, `.aider.chat.history.md`, `.specstory/history/`, session/cache/lock files. An agent's *configuration* is source and never matches | +| **Agent artefacts** | **every path tracked in the tree at the head** | an agent's *output* is committed: `.claude/logs/`, `.claude/plans/`, `.claude/.credentials.json`, `settings.local.json`, `.aider.chat.history.md`, `.specstory/history/`, session/cache/lock files. An agent's *configuration* is source and never matches | Default attribution patterns match the **shape of an attribution**, not the mention of a vendor. Measured on `devcontainer-template`'s real history, a @@ -90,6 +90,13 @@ never at a keyword. Two measurements on the fleet say why: empty on purpose by the devcontainer image, and the placeholder is what makes it exist. `sessions/` is therefore not matched at all. +One entry earns its place for a different reason. `.claude/.credentials.json` +is Claude Code's OAuth token store, and the secrets check does not save you +from it: that check reads only the lines a push *adds*, so a credentials file +committed once is never looked at again β€” and a `sk-ant-oat01-…` token carries +hyphens where the `sk-[a-zA-Z0-9]` pattern expects none. Here it stays red for +as long as the file is tracked. + **Not matched, by decision rather than by omission**: everything above, plus editor configuration (`.vscode/`, `.idea/`, `.zed/`), `.devcontainer/`, and markdown instructions (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`). Nothing is diff --git a/scripts/agent-paths.txt b/scripts/agent-paths.txt index 2e2a5c2..05cefa2 100644 --- a/scripts/agent-paths.txt +++ b/scripts/agent-paths.txt @@ -56,6 +56,14 @@ # of one in a repository is the same exhaust. (^|/)\.claude/(logs|plans|todos|shell-snapshots|statsig|paste-cache|session-env|backups|downloads|projects|ide|chrome)/ (^|/)\.claude/history\.jsonl$ +# The OAuth token store. Nothing in a repository has less business being there, +# and the secrets check would not save you: it reads only the lines a push +# ADDS, and a `sk-ant-oat01-…` token carries hyphens where its `sk-[a-zA-Z0-9]` +# pattern expects none. Tracked once, this stays tracked and stays missed. +(^|/)\.claude/\.credentials\.json$ +# Account and machine state the server pushes down β€” not configuration anyone +# wrote, and meaningless in another checkout. +(^|/)\.claude/(policy-limits|remote-settings)\.json$ (^|/)\.claude/[^/]*\.lock$ # A personal machine's overrides, which Claude Code itself keeps out of git. # Committing one imposes one developer's permissions on everybody. diff --git a/scripts/post-commit.sh b/scripts/post-commit.sh index f9b17eb..e8958e6 100755 --- a/scripts/post-commit.sh +++ b/scripts/post-commit.sh @@ -26,13 +26,14 @@ # 4. format β€” conventional-commit subject on the range's non-merge # commits (project convention, see devcontainer-template). # 5. secrets β€” no credential-shaped ADDED lines in the range's diff. -# 6. artefacts β€” no AI agent's tooling configuration TRACKED in the tree at -# the head (.claude/, .cursor/, .aider.*, …). The tree and -# not the range: the directory this rule exists to remove -# was merged long before the rule existed, and a range check -# would call every later pull request clean while it sat -# there. Editor configuration is untouched β€” an editor is -# not an agent. +# 6. artefacts β€” nothing an AI agent WROTE is TRACKED in the tree at the +# head: session logs, chat transcripts, plan files, locks, +# caches, the credentials store, personal overrides. Its +# CONFIGURATION is source and is never matched β€” +# .claude/agents/, commands/, skills/, settings.json, +# .mcp.json, .cursorrules. The tree and not the range: an +# artefact merged before the rule existed would otherwise +# leave every later pull request clean while it sat there. # # Deliberately NOT here: lint/build/test. Every repo's own CI already runs # those server-side, so --no-verify never bypassed them in the first place. @@ -273,11 +274,17 @@ if [ "$SECRETS" = "true" ] && [ -n "$RANGE" ]; then fi # --- 4. Agent artefacts (the tree at the head) ------------------------------- -# Scope is the tree, not the range, and that is the whole point. The `.claude/` -# that prompted this rule was merged into a trunk months before the rule -# existed; a range check sees only what a change adds, so every later pull -# request would have been called clean while the directory sat there. Reading -# the tracked paths means the gate stays red until it is actually gone. +# What an agent WROTE, never what a human wrote for it. The configuration is +# source β€” someone authored `.claude/agents/`, reviewed it, and wants the next +# person who clones to have it. The exhaust is not: a session log, a +# transcript, a plan, a lock, a cache, a credentials store, a personal +# override. See agent-paths.txt for where exactly the line falls and for the +# fleet measurements that put it there. +# +# Scope is the tree, not the range, and that is the point. The artefact that +# prompted this rule was merged into a trunk long before the rule existed; a +# range check sees only what a change adds, so every later pull request would +# have been called clean while it sat there. # # Which it can afford to be, because gone is cheap here. A tainted commit # message needs rewrite-history.sh and new SHAs for every descendant; a tracked diff --git a/tests/run.sh b/tests/run.sh index e286a08..21886d5 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -400,6 +400,18 @@ echo '{}' > "$d/.claude/history.jsonl" git -C "$d" add -A; git -C "$d" commit -qm "chore: a lock and a history" check "a lock file and history.jsonl are refused" 1 "$d" +# The worst thing a `.claude/` can carry, and the one the secrets check cannot +# save you from: it reads only the lines a push ADDS, so a credentials file +# committed once is never looked at again. +d=$(mkrepo); mkdir -p "$d/.claude"; echo '{"t":"x"}' > "$d/.claude/.credentials.json" +git -C "$d" add -A; git -C "$d" commit -qm "chore: oauth token store" +check "a committed credentials file is refused" 1 "$d" + +d=$(mkrepo); mkdir -p "$d/.claude" +echo '{}' > "$d/.claude/policy-limits.json"; echo '{}' > "$d/.claude/remote-settings.json" +git -C "$d" add -A; git -C "$d" commit -qm "chore: machine state" +check "server-pushed machine state is refused" 1 "$d" + d=$(mkrepo); echo 'chat' > "$d/.aider.chat.history.md" git -C "$d" add -A; git -C "$d" commit -qm "chore: aider transcript" check "aider's transcript is refused while its conf is not" 1 "$d" From c446c579f258d9e57edd5d6583c3bc5fba76ad09 Mon Sep 17 00:00:00 2001 From: Kodflow <133899878+kodflow@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:10:39 +0200 Subject: [PATCH 3/5] test(gate): pin aider's version-suffixed tag cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anchoring `(^|/)\.aider\.tags\.cache` with `$` reads like an obvious tightening and would retire the rule instead. aider names the directory after the cache format version β€” aider/repomap.py: CACHE_VERSION = 3 # 4 on recent builds TAGS_CACHE_DIR = f".aider.tags.cache.v{CACHE_VERSION}" so a bare `.aider.tags.cache` never exists on disk and the anchored pattern would match nothing at all. Verified: the anchored form scores 0 matches against `.aider.tags.cache.v3/cache.db`, the unanchored one 1. The comment now says why the anchor is absent, and a test fails if anyone adds it. Also names the two aider config files that are deliberately not matched, so the same reasoning does not have to be redone for them. --- scripts/agent-paths.txt | 10 ++++++++-- tests/run.sh | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/agent-paths.txt b/scripts/agent-paths.txt index 05cefa2..98faa77 100644 --- a/scripts/agent-paths.txt +++ b/scripts/agent-paths.txt @@ -71,11 +71,17 @@ # Timestamped backups the tool leaves beside a file it rewrote. (^|/)\.claude/[^/]*\.bak([-.][^/]*)?$ # --- aider ------------------------------------------------------------------- -# The transcripts and the tag cache. `.aider.conf.yml` is configuration and is -# deliberately absent from this list. +# The three transcripts, whose names are aider's documented defaults, and the +# tag cache. `.aider.conf.yml`, `.aider.model.settings.yml` and +# `.aider.model.metadata.json` are configuration and are deliberately absent. (^|/)\.aider\.chat\.history\.md$ (^|/)\.aider\.input\.history$ (^|/)\.aider\.llm\.history$ +# NO `$` HERE, ON PURPOSE. The cache directory carries the cache format +# version β€” aider/repomap.py: `TAGS_CACHE_DIR = f".aider.tags.cache.v{CACHE_VERSION}"`, +# currently v3 and v4 β€” so a bare `.aider.tags.cache` never exists on disk. +# Anchoring this would match nothing at all and quietly retire the rule. A +# test pins `.aider.tags.cache.v3/` for exactly that reason. (^|/)\.aider\.tags\.cache # --- SpecStory --------------------------------------------------------------- # Recording the chat into the repository is the product's whole purpose, which diff --git a/tests/run.sh b/tests/run.sh index 21886d5..099d855 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -416,6 +416,14 @@ d=$(mkrepo); echo 'chat' > "$d/.aider.chat.history.md" git -C "$d" add -A; git -C "$d" commit -qm "chore: aider transcript" check "aider's transcript is refused while its conf is not" 1 "$d" +# aider names the cache directory after the cache FORMAT VERSION +# (`TAGS_CACHE_DIR = f".aider.tags.cache.v{CACHE_VERSION}"`), so a bare +# `.aider.tags.cache` never exists. Anchoring that pattern with `$` β€” which +# reads like an obvious tightening β€” would match nothing and retire the rule. +d=$(mkrepo); mkdir -p "$d/.aider.tags.cache.v3"; echo 'x' > "$d/.aider.tags.cache.v3/cache.db" +git -C "$d" add -A; git -C "$d" commit -qm "chore: aider tag cache" +check "the version-suffixed tag cache is refused" 1 "$d" + d=$(mkrepo); mkdir -p "$d/.specstory/history"; echo 'chat' > "$d/.specstory/history/2026-01-01.md" git -C "$d" add -A; git -C "$d" commit -qm "chore: recorded chat" check "a recorded chat transcript is refused" 1 "$d" From 8c074bab6f987b72d23691bf6e204d351967f4fe Mon Sep 17 00:00:00 2001 From: Kodflow <133899878+kodflow@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:13:27 +0200 Subject: [PATCH 4/5] test(gate): cover every pattern in both directions, by construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of which patterns had a case found eight with none: `.claude/*.bak-*`, aider's input and llm histories, the amazonq, codeium and qodo caches, and Cursor's local override. Adding eight more `check` calls would have left the same gap open for the ninth pattern. Instead, two data-driven cases. One builds a repository holding a representative path for every pattern in agent-paths.txt and asserts each one appears in the annotations β€” naming the paths that went dark rather than reporting a bare exit code. The other builds a repository of thirty authored files, the half of the contract that matters more, so a pattern that grows a little too wide fails here instead of on a fleet repository's pull requests. Verified to discriminate: removing `(^|/)\.amazonq/cache/` makes the first case fail with "not matched: .amazonq/cache/a" while the rest stay green. --- tests/run.sh | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/run.sh b/tests/run.sh index 099d855..7f6ea04 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -441,6 +441,63 @@ echo '{}' > "$d/.devcontainer/images/.claude/logs/session.jsonl" git -C "$d" add -A; git -C "$d" commit -qm "chore: image payload with a stray log" check "a log nested in the devcontainer payload is still refused" 1 "$d" +echo "== agent artefacts: one case per pattern, both ways ==" +# Exhaustive by construction rather than by discipline. Every pattern in +# agent-paths.txt gets a representative path here, and the assertion names the +# ones that went dark β€” so adding a pattern without a case, or breaking one +# nobody happened to exercise, fails loudly instead of quietly. +REFUSED=( + .claude/logs/a.jsonl .claude/plans/p.md .claude/todos/t.json + .claude/shell-snapshots/s.sh .claude/statsig/s.json .claude/paste-cache/p + .claude/session-env/e .claude/backups/b .claude/downloads/d + .claude/projects/p .claude/ide/i .claude/chrome/c + .claude/history.jsonl .claude/.credentials.json + .claude/policy-limits.json .claude/remote-settings.json + .claude/scheduled_tasks.lock .claude/settings.local.json + .claude/settings.json.bak-20260101 + .aider.chat.history.md .aider.input.history .aider.llm.history + .aider.tags.cache.v3/cache.db + .specstory/history/2026-01-01.md + .continue/sessions/a.json .continue/index/a .continue/dev_data/a + .goose/sessions/a .goose/logs/a.log + .amazonq/cache/a .codeium/cache/a .qodo/cache/a .qodo/history/a + .cursor/mcp.local.json +) +d=$(mkrepo) +for f in "${REFUSED[@]}"; do mkdir -p "$d/$(dirname "$f")"; echo x > "$d/$f"; done +git -C "$d" add -A; git -C "$d" commit -qm "chore: one artefact per pattern" +out="$(cd "$d" && env GITHUB_STEP_SUMMARY=/dev/null bash "$GATE" HEAD HEAD~1..HEAD 2>&1)" +missed="" +for f in "${REFUSED[@]}"; do + printf '%s' "$out" | grep -qF "file=$f" || missed="${missed:+$missed }$f" +done +if [ -z "$missed" ]; then + PASS=$((PASS+1)); printf ' ok %s (%s paths)\n' "every pattern refuses its own artefact" "${#REFUSED[@]}" +else + FAIL=$((FAIL+1)); printf ' FAIL %s β€” not matched: %s\n' "every pattern refuses its own artefact" "$missed" +fi +rm -rf "$d" + +# The other half of the contract, and the half that matters more: everything a +# human authored for the agent stays. A pattern that grows a little too wide +# lands here rather than on a repository's pull requests. +ALLOWED=( + .claude/agents/reviewer.md .claude/agents/routing-table.jsonl + .claude/commands/ship.md .claude/skills/deploy.md .claude/docs/pattern.md + .claude/scripts/hook.sh .claude/templates/t.tpl .claude/workflows/w.yml + .claude/settings.json .claude/.claude.json .claude/features.json + .claude/sessions/.gitkeep .claude/db + .mcp.json .cursorrules .cursor/rules/style.mdc + .aider.conf.yml .aider.model.settings.yml .aider.model.metadata.json + .roomodes .clinerules .windsurfrules .codex/config.toml .gemini/settings.json + CLAUDE.md AGENTS.md GEMINI.md + .vscode/settings.json .idea/workspace.xml .devcontainer/devcontainer.json +) +d=$(mkrepo) +for f in "${ALLOWED[@]}"; do mkdir -p "$d/$(dirname "$f")"; echo x > "$d/$f"; done +git -C "$d" add -A; git -C "$d" commit -qm "chore: everything a human authored" +check "no authored file is ever refused (${#ALLOWED[@]} paths)" 0 "$d" + echo "== agent artefacts: scope, exemptions and reporting ==" # The tree, not the range: the artefact arrives in an ancestor and the change # that added it is long merged. A range-scoped check would call this clean. From 3596785e9e8d8c2d051605cdf286665b34c1b41b Mon Sep 17 00:00:00 2001 From: Kodflow <133899878+kodflow@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:16:53 +0200 Subject: [PATCH 5/5] docs(gate): name the allowed paths instead of pointing at "everything above" Inserting the credentials paragraph moved what "everything above" refers to: the sentence listing what is NOT matched now reached back over `.claude/.credentials.json`, which this branch refuses. The README said the opposite of the patterns file for the one entry where being wrong matters most. Replaced with the configuration paths themselves. Verified path by path against agent-paths.txt: every path the README calls allowed is unmatched by the pattern set, and the credentials file is still refused. --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5fe187f..5383e80 100644 --- a/README.md +++ b/README.md @@ -97,11 +97,14 @@ committed once is never looked at again β€” and a `sk-ant-oat01-…` token carri hyphens where the `sk-[a-zA-Z0-9]` pattern expects none. Here it stays red for as long as the file is tracked. -**Not matched, by decision rather than by omission**: everything above, plus -editor configuration (`.vscode/`, `.idea/`, `.zed/`), `.devcontainer/`, and -markdown instructions (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`). Nothing is -matched *because* it sits under an agent directory β€” the directory is -legitimate; only the listed children are not. A log dropped inside +**Not matched, by decision rather than by omission**: an agent's configuration +β€” `.claude/agents/`, `commands/`, `skills/`, `docs/`, `scripts/`, +`settings.json`, `.mcp.json`, `.cursorrules`, `.cursor/rules/`, `.clinerules`, +`.aider.conf.yml` β€” plus the two measured cases above, editor configuration +(`.vscode/`, `.idea/`, `.zed/`), `.devcontainer/`, and markdown instructions +(`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`). Nothing is matched *because* it sits +under an agent directory β€” the directory is legitimate; only the listed +children are not. A log dropped inside `.devcontainer/images/.claude/` is still a log, though: no parent grants immunity, or "put it under `.devcontainer/`" becomes the way around the rule.