Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,30 @@ 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

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`;
Expand Down
43 changes: 38 additions & 5 deletions claude/hooks/bash-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 -------------------------------------------
Expand Down
5 changes: 5 additions & 0 deletions claude/settings.fragment.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
}
]
},
"attribution": {
"commit": "",
"pr": "",
"sessionUrl": false
},
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh",
Expand Down
30 changes: 27 additions & 3 deletions claude/test-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ expect deny run_bash 'git commit -m "feat: x" -m "Co-Authored-By: Claude <norepl
expect deny run_bash 'git commit -m "fix: y

🤖 Generated with Claude Code"'
expect deny run_bash 'gh pr create --title x --body "done

\U0001F916 Generated with Claude Code"' 'PR body: generated-with footer'
expect deny run_bash 'gh pr create --title x --body "see https://claude.ai/code/session_017Bv"' 'PR body: session URL'
expect deny run_bash 'gh pr comment 3 --body "Co-Authored-By: Claude"' 'PR comment: trailer'
expect deny run_bash 'gh release create v1.0.0 --notes "ships. https://claude.ai/code/session_017Bv"' 'release notes: session URL'
expect deny run_bash 'gh issue create --title x --body "Co-Authored-By: Claude"' 'issue body: trailer'
expect deny run_bash 'git tag -a v1 -m "Co-Authored-By: Claude"' 'tag message: trailer'
expect deny run_bash 'rm -rf ~'
expect deny run_bash 'rm -rf /'
expect deny run_bash 'rm -rf $HOME/'
Expand All @@ -53,9 +61,13 @@ expect deny run_bash 'ls -la'
echo
echo "== bash-guard: should ALLOW =="
expect allow run_bash 'git commit -m "feat(hooks): add global guards"'
expect allow run_bash 'gh pr create --body "closes #1

🤖 Generated with Claude Code"'
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'
expect allow run_bash 'rm -rf node_modules'
expect allow run_bash 'rm -f tmp.log'
Expand All @@ -73,6 +85,18 @@ grep foo bar
ls -la
EOF" 'heredoc write whose body mentions grep/ls'

echo
echo "== bash-guard: body files on disk =="
# The case that actually leaked. `gh` reads bodies from disk as often as from
# the command line, so a payload-only test can never reach this branch.
bodydir=$(mktemp -d)
trap 'rm -rf "$bodydir"' EXIT
printf 'a normal description\n\nhttps://claude.ai/code/session_017Bv\n' > "$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'
Expand Down
Loading