Skip to content

Commit 12364b6

Browse files
committed
fix(hooks): remove echo/printf/find/awk from skip list; key snapshot by command hash
Three correctness gaps addressed in the snapshot-pre-bash / track-bash-writes hook pair: 1. echo, printf, find, and awk were listed as read-only skip candidates but all four can write files (echo/printf via redirections, find via -exec/-delete, awk via getline/redirection). Remove them from the skip list so their file writes are captured in session-edits.log. 2. The snapshot file was keyed only by project root hash, causing a race when Claude Code issues multiple Bash tool calls in parallel: call B's pre-hook would overwrite call A's snapshot before A's post-hook ran, silently dropping A's file writes. Fix by including a hash of the command string in the filename so each concurrent call gets a distinct snapshot file.
1 parent 9a52c7c commit 12364b6

2 files changed

Lines changed: 37 additions & 8 deletions

File tree

.claude/hooks/snapshot-pre-bash.sh

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,26 @@ if [ -z "$COMMAND" ]; then
2323
exit 0
2424
fi
2525

26-
# Skip read-only commands that can never write files — reduces snapshot overhead
27-
# for the most common Bash calls (ls, cat, grep, git log, git status, etc.).
26+
# Skip commands that can NEVER write files — reduces snapshot overhead for the
27+
# most common read-only Bash calls. Only include commands that have no
28+
# write-capable flags/modes at all. Notably absent:
29+
# - echo, printf — write files via shell redirections (echo … > file)
30+
# - find — can write via -exec sed -i, -exec cp, -delete, etc.
31+
# - awk — can write via redirection or getline
2832
# sed is intentionally NOT in this list because `sed -i` modifies files in-place.
29-
if echo "$COMMAND" | grep -qE '^\s*(ls|cat|head|tail|grep|find|git\s+(log|status|diff|show|branch|remote|fetch|rev-parse|stash\s+list|ls-files|blame|describe|tag|config\s+--get)|gh\s+(pr|issue|repo)\s+(view|list|status)|echo|printf|pwd|which|node\s+-e|node\s+-p|npx\s+--version|wc|sort|uniq|awk)\b'; then
33+
if echo "$COMMAND" | grep -qE '^\s*(ls|cat|head|tail|grep|git\s+(log|status|diff|show|branch|remote|fetch|rev-parse|stash\s+list|ls-files|blame|describe|tag|config\s+--get)|gh\s+(pr|issue|repo)\s+(view|list|status)|pwd|which|node\s+-e|node\s+-p|npx\s+--version|wc|sort|uniq)\b'; then
3034
exit 0
3135
fi
3236

3337
# Resolve the project root (worktree-aware — each worktree has its own .claude/)
3438
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
3539

36-
# Key the snapshot file to the project root so parallel worktrees don't collide.
37-
# Use a simple hash of the path — just enough to be unique per worktree.
40+
# Key the snapshot file to (project root, command) so concurrent Bash calls
41+
# within the same session don't overwrite each other's baseline.
42+
# Claude Code can issue multiple Bash tool calls in parallel; using just the
43+
# project hash would mean call B's pre-hook overwrites call A's snapshot before
44+
# A's post-hook runs, silently dropping A's file writes from session-edits.log.
45+
# Including a hash of the command makes each concurrent call use a distinct file.
3846
PROJECT_HASH=$(echo "$PROJECT_DIR" | node -e "
3947
const crypto = require('crypto');
4048
let d='';
@@ -44,7 +52,16 @@ PROJECT_HASH=$(echo "$PROJECT_DIR" | node -e "
4452
});
4553
" 2>/dev/null) || PROJECT_HASH="default"
4654

47-
SNAPSHOT_FILE="/tmp/claude-bash-snapshot-${PROJECT_HASH}.txt"
55+
CMD_HASH=$(echo "$COMMAND" | node -e "
56+
const crypto = require('crypto');
57+
let d='';
58+
process.stdin.on('data',c=>d+=c);
59+
process.stdin.on('end',()=>{
60+
process.stdout.write(crypto.createHash('sha1').update(d.trim()).digest('hex').slice(0,8));
61+
});
62+
" 2>/dev/null) || CMD_HASH="default"
63+
64+
SNAPSHOT_FILE="/tmp/claude-bash-snapshot-${PROJECT_HASH}-${CMD_HASH}.txt"
4865

4966
# Capture current git status --porcelain.
5067
# Lines look like: "XY filename" or "XY orig -> dest" (rename).

.claude/hooks/track-bash-writes.sh

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ fi
2727
# Resolve the project root (worktree-aware — each worktree has its own .claude/)
2828
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
2929

30-
# Reproduce the same project hash used by snapshot-pre-bash.sh
30+
# Reproduce the same snapshot path used by snapshot-pre-bash.sh.
31+
# The snapshot is keyed by (project root, command) so concurrent Bash calls
32+
# within the same session each get a distinct file — preventing parallel calls
33+
# from overwriting each other's baseline.
3134
PROJECT_HASH=$(echo "$PROJECT_DIR" | node -e "
3235
const crypto = require('crypto');
3336
let d='';
@@ -37,7 +40,16 @@ PROJECT_HASH=$(echo "$PROJECT_DIR" | node -e "
3740
});
3841
" 2>/dev/null) || PROJECT_HASH="default"
3942

40-
SNAPSHOT_FILE="/tmp/claude-bash-snapshot-${PROJECT_HASH}.txt"
43+
CMD_HASH=$(echo "$COMMAND" | node -e "
44+
const crypto = require('crypto');
45+
let d='';
46+
process.stdin.on('data',c=>d+=c);
47+
process.stdin.on('end',()=>{
48+
process.stdout.write(crypto.createHash('sha1').update(d.trim()).digest('hex').slice(0,8));
49+
});
50+
" 2>/dev/null) || CMD_HASH="default"
51+
52+
SNAPSHOT_FILE="/tmp/claude-bash-snapshot-${PROJECT_HASH}-${CMD_HASH}.txt"
4153

4254
# If there is no snapshot (hook was not installed yet, or the pre-hook was
4355
# skipped for a read-only command) we have no baseline — exit cleanly.

0 commit comments

Comments
 (0)