diff --git a/README.md b/README.md index 0ef05aa..5383e80 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/`, `.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 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,64 @@ 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 - -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. - -It can afford that scope because gone is cheap here, and this is the one place -the gate refuses something without demanding a rewrite: +### Agent artefacts: configuration is source, exhaust is not + +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. + +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. + +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**: 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. + +### 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 +162,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 +248,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..98faa77 100644 --- a/scripts/agent-paths.txt +++ b/scripts/agent-paths.txt @@ -4,102 +4,98 @@ # 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$ +# 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. +(^|/)\.claude/settings\.local\.json$ +# Timestamped backups the tool leaves beside a file it rewrote. +(^|/)\.claude/[^/]*\.bak([-.][^/]*)?$ +# --- aider ------------------------------------------------------------------- +# 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 +# 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..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 @@ -454,9 +461,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 +485,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..7f6ea04 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,201 @@ 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); 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" - -# 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" +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" + +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" + +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); 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" + +# 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); 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" +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: 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" + +# 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" + +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: 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. +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 +551,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 +569,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 +585,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 +613,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=$?