diff --git a/claude/README.md b/claude/README.md index ecc39ff..3fab408 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` | 54 golden inputs, both directions, plus the on-disk body-file branch | ## bash-guard.sh @@ -24,9 +24,21 @@ 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`, 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. + + 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, 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 c8edc56..dd71219 100755 --- a/claude/hooks/bash-guard.sh +++ b/claude/hooks/bash-guard.sh @@ -21,11 +21,44 @@ 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_|🤖' +# 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 + 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..36c964c 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'