From 43b7b7c4c349581904bd53bed6e13f441bb4f85c Mon Sep 17 00:00:00 2001 From: Nicolas Marino <26677779+NicolasMarino@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:27:56 -0500 Subject: [PATCH 1/2] fix(claude): the attribution guard was scoped to commits, and it leaked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bash-guard denied AI attribution in `git commit` only. The README said so on purpose: PR and issue bodies were allowed to carry the trailer. That decision shipped a Claude session URL onto three pull requests of a public repository. The rule was never about the word "commits" — it is about anything that leaves this machine carrying the user's name. Such a link is not a credential, but it is a private identifier, and publishing it is not the agent's call to make. The check now covers every publishing verb: git commit, git tag, gh pr, gh release, gh issue. It also opens body files. `gh` reads bodies from disk as often as from the command line, and a footer sitting in that file is invisible to any check on the command text — which is exactly how one got published. Non-publishing commands are untouched, so searching the repository for these strings still works. A guard that blocks the hunt for a leak is worse than no guard. settings.fragment.json also gains attribution.commit/pr = "" and sessionUrl = false, which turns the footers off at the source. The hook is the net under that, not a replacement for it. The suite had a case asserting the old behaviour, expected to pass. It encoded the bug, so it is now a denial. 51 golden inputs, plus a temp-file block for the on-disk body-file branch that a payload-only test can never reach. --- claude/README.md | 19 ++++++++++++----- claude/hooks/bash-guard.sh | 40 ++++++++++++++++++++++++++++++----- claude/settings.fragment.json | 5 +++++ claude/test-hooks.sh | 27 ++++++++++++++++++++--- 4 files changed, 78 insertions(+), 13 deletions(-) diff --git a/claude/README.md b/claude/README.md index ecc39ff..049488b 100644 --- a/claude/README.md +++ b/claude/README.md @@ -15,8 +15,8 @@ Installed by `scripts/claude.sh`, which is called from `install.sh`. | `hooks/bash-guard.sh` | `PreToolUse` on `Bash` — commit attribution, destructive commands, CLI preference | | `hooks/write-guard.sh` | `PreToolUse` on `Edit\|Write\|MultiEdit` — credential scan | | `statusline.sh` | Status line: model, branch, session cost, context and rate-limit budget | -| `settings.fragment.json` | The `hooks` and `statusLine` blocks merged into `~/.claude/settings.json` | -| `test-hooks.sh` | 35 golden inputs, both directions | +| `settings.fragment.json` | The `hooks`, `statusLine` and `attribution` blocks merged into `~/.claude/settings.json` | +| `test-hooks.sh` | 51 golden inputs, both directions, plus the on-disk body-file branch | ## bash-guard.sh @@ -24,9 +24,18 @@ Three checks in one process, ~30ms per call. `PreToolUse` on `Bash` runs on every shell command the agent issues, so it has to stay well under the ~100ms budget where latency starts being felt. -1. **Commit attribution.** Denies `git commit` whose message carries - `Co-Authored-By`, `Generated with Claude`, or 🤖. Scoped to `git commit` on - purpose: `gh pr create --body` is allowed to carry the trailer. +1. **AI attribution.** Denies any publishing command — `git commit`, + `git tag`, `gh pr`, `gh release`, `gh issue` — carrying `Co-Authored-By`, + `Generated with Claude`, a `claude.ai/code/session_` URL, or 🤖. Body text + passed as a file (`--body-file`, `--notes-file`) is opened and scanned too, + because `gh` reads bodies from disk as often as from the command line. + + This was once scoped to `git commit` on purpose, on the reading that the + rule said "commits". A session URL then went out on three pull requests of + a public repository. The rule was never about the word: it is about anything + that leaves this machine carrying the user's name. Non-publishing commands + are untouched, so `rg "claude.ai/code/session_"` still runs — a guard that + blocks the hunt for a leak is worse than no guard. 2. **Destructive commands.** Denies recursive `rm` against root, home, or a bare wildcard; `git push --force` without `--force-with-lease`; diff --git a/claude/hooks/bash-guard.sh b/claude/hooks/bash-guard.sh index c8edc56..4d049b5 100755 --- a/claude/hooks/bash-guard.sh +++ b/claude/hooks/bash-guard.sh @@ -21,11 +21,41 @@ deny() { exit 0 } -# --- 1. commit attribution --------------------------------------------- -# Scoped to `git commit`. PR and issue bodies are allowed to carry the trailer. -if grep -qE '(^|[[:space:];&|])git[[:space:]]+commit' <<<"$cmd" \ - && grep -qiE 'co-authored-by|generated with .{0,3}claude|🤖' <<<"$cmd"; then - deny "This user never puts AI attribution in commit messages. Remove the Co-Authored-By trailer and any 'Generated with Claude' line, then commit again with the conventional-commit subject and body only." +# --- 1. AI attribution ------------------------------------------------- +# Every publishing verb, not just `git commit`. This was scoped to commits on +# purpose once, on the reading that the rule said "commits" — and a session URL +# went out on three pull requests of a public repository. The rule was never +# about the word: it is about anything that leaves this machine carrying the +# user's name. A claude.ai session link is not a credential, but it is a private +# identifier, and publishing it is not the agent's call. +# +# Only publishing verbs are inspected, so searching for these strings still +# works. A guard that blocks the hunt for a leak is worse than no guard. +attribution='co-authored-by|generated with .{0,3}claude|claude\.ai/code/session_|🤖' +publishing='(^|[[:space:];&|])(git[[:space:]]+(commit|tag)|gh[[:space:]]+(pr|release|issue))' + +if grep -qE "$publishing" <<<"$cmd"; then + if grep -qiE "$attribution" <<<"$cmd"; then + deny "Nothing published from this machine carries AI attribution: not commits, PR bodies, comments, release notes or issues. Remove the Co-Authored-By trailer, any 'Generated with Claude' line, and any claude.ai session URL, then run it again." + fi + + # `gh` reads bodies from disk as often as from the command line, and a footer + # sitting in that file is invisible to every check on the command text. That + # is exactly how one got published. + read -ra parts <<<"$cmd" + for i in "${!parts[@]}"; do + case "${parts[$i]}" in + --body-file|--notes-file|--file|-F) bodyfile="${parts[$((i + 1))]}" ;; + --body-file=*|--notes-file=*|--file=*) bodyfile="${parts[$i]#*=}" ;; + *) continue ;; + esac + bodyfile="${bodyfile%\"}"; bodyfile="${bodyfile#\"}" + bodyfile="${bodyfile%\'}"; bodyfile="${bodyfile#\'}" + [ -f "$bodyfile" ] || continue + if grep -qiE "$attribution" "$bodyfile"; then + deny "The body file '$bodyfile' carries AI attribution. Nothing published from this machine does: not commits, PR bodies, comments, release notes or issues. Strip it from the file and run the command again." + fi + done fi # --- 2. destructive commands ------------------------------------------- diff --git a/claude/settings.fragment.json b/claude/settings.fragment.json index 54023e6..87bce87 100644 --- a/claude/settings.fragment.json +++ b/claude/settings.fragment.json @@ -35,6 +35,11 @@ } ] }, + "attribution": { + "commit": "", + "pr": "", + "sessionUrl": false + }, "statusLine": { "type": "command", "command": "~/.claude/statusline.sh", diff --git a/claude/test-hooks.sh b/claude/test-hooks.sh index 68b7c13..60e5e56 100755 --- a/claude/test-hooks.sh +++ b/claude/test-hooks.sh @@ -35,6 +35,14 @@ expect deny run_bash 'git commit -m "feat: x" -m "Co-Authored-By: Claude "$bodydir/dirty.md" +printf 'a normal description, nothing else\n' > "$bodydir/clean.md" +expect deny run_bash "gh pr create --title x --body-file $bodydir/dirty.md" 'body file: session URL on disk' +expect deny run_bash "gh release create v1 --notes-file $bodydir/dirty.md" 'notes file: session URL on disk' +expect allow run_bash "gh pr create --title x --body-file $bodydir/clean.md" 'body file: clean' + echo echo "== write-guard: should DENY ==" expect deny run_write 'const key = "'AKIA'IOSFODNN7EXAMPLE"' 'aws access key id' From 631d53310e6a3a7a2dea21d245acf9199bce846b Mon Sep 17 00:00:00 2001 From: Nicolas Marino <26677779+NicolasMarino@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:29:32 -0500 Subject: [PATCH 2/2] fix(claude): match publishing by subcommand, not by noun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut matched `gh pr` whole, so `gh pr view` and `gh pr list` counted as publishing. Reading a pull request to check it for a leak was denied, which is precisely backwards — the guard exists to let that check happen. Found by using it: auditing the very PR that carries this change was blocked. Now only the writing subcommands match: create, edit, comment, merge, ready, reopen, close, upload. Three golden inputs cover the reads, bringing the suite to 54. --- claude/README.md | 11 +++++++---- claude/hooks/bash-guard.sh | 5 ++++- claude/test-hooks.sh | 3 +++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/claude/README.md b/claude/README.md index 049488b..3fab408 100644 --- a/claude/README.md +++ b/claude/README.md @@ -16,7 +16,7 @@ Installed by `scripts/claude.sh`, which is called from `install.sh`. | `hooks/write-guard.sh` | `PreToolUse` on `Edit\|Write\|MultiEdit` — credential scan | | `statusline.sh` | Status line: model, branch, session cost, context and rate-limit budget | | `settings.fragment.json` | The `hooks`, `statusLine` and `attribution` blocks merged into `~/.claude/settings.json` | -| `test-hooks.sh` | 51 golden inputs, both directions, plus the on-disk body-file branch | +| `test-hooks.sh` | 54 golden inputs, both directions, plus the on-disk body-file branch | ## bash-guard.sh @@ -25,7 +25,8 @@ every shell command the agent issues, so it has to stay well under the ~100ms budget where latency starts being felt. 1. **AI attribution.** Denies any publishing command — `git commit`, - `git tag`, `gh pr`, `gh release`, `gh issue` — carrying `Co-Authored-By`, + `git tag`, and `gh pr|release|issue` followed by a writing subcommand + (`create`, `edit`, `comment`, `merge`, …) — carrying `Co-Authored-By`, `Generated with Claude`, a `claude.ai/code/session_` URL, or 🤖. Body text passed as a file (`--body-file`, `--notes-file`) is opened and scanned too, because `gh` reads bodies from disk as often as from the command line. @@ -34,8 +35,10 @@ budget where latency starts being felt. rule said "commits". A session URL then went out on three pull requests of a public repository. The rule was never about the word: it is about anything that leaves this machine carrying the user's name. Non-publishing commands - are untouched, so `rg "claude.ai/code/session_"` still runs — a guard that - blocks the hunt for a leak is worse than no guard. + are untouched, and reading verbs are matched by subcommand rather than by + noun, so a plain search over the tree and `gh pr view … | rg` both still + run. A guard that blocks the hunt for a leak is worse than no guard — the + first draft matched `gh pr` whole and denied exactly that. 2. **Destructive commands.** Denies recursive `rm` against root, home, or a bare wildcard; `git push --force` without `--force-with-lease`; diff --git a/claude/hooks/bash-guard.sh b/claude/hooks/bash-guard.sh index 4d049b5..dd71219 100755 --- a/claude/hooks/bash-guard.sh +++ b/claude/hooks/bash-guard.sh @@ -32,7 +32,10 @@ deny() { # Only publishing verbs are inspected, so searching for these strings still # works. A guard that blocks the hunt for a leak is worse than no guard. attribution='co-authored-by|generated with .{0,3}claude|claude\.ai/code/session_|🤖' -publishing='(^|[[:space:];&|])(git[[:space:]]+(commit|tag)|gh[[:space:]]+(pr|release|issue))' +# The subcommand matters: `gh pr view` and `gh release list` read, they do not +# publish. Matching the noun alone denied reading a PR to check it for a leak, +# which is the opposite of the point. +publishing='(^|[[:space:];&|])(git[[:space:]]+(commit|tag)|gh[[:space:]]+(pr|release|issue)[[:space:]]+(create|edit|comment|merge|ready|reopen|close|upload))' if grep -qE "$publishing" <<<"$cmd"; then if grep -qiE "$attribution" <<<"$cmd"; then diff --git a/claude/test-hooks.sh b/claude/test-hooks.sh index 60e5e56..36c964c 100755 --- a/claude/test-hooks.sh +++ b/claude/test-hooks.sh @@ -63,6 +63,9 @@ echo "== bash-guard: should ALLOW ==" expect allow run_bash 'git commit -m "feat(hooks): add global guards"' expect allow run_bash 'gh pr create --title x --body "a clean description"' 'PR body: nothing to hide' expect allow run_bash 'gh release view v1.0.0 --json body' 'reading a release is not publishing' +expect allow run_bash 'gh pr view 5 --json body | rg -c "co-authored-by"' 'auditing a PR for the trailer is a read' +expect allow run_bash 'gh pr list --json title' 'listing PRs is a read' +expect allow run_bash 'gh pr diff 5' 'diffing a PR is a read' expect allow run_bash 'rg -n "claude.ai/code/session_" README.md' 'hunting for the leak is not the leak' expect allow run_bash 'rg -c "co-authored-by" *.md' 'auditing for the trailer stays allowed' expect allow run_bash 'rm -rf ./build'