The Swift 6 checker ran clean for three weeks. Then it let a push through with an unmarked @unchecked Sendable sitting right in the diff, and the bug wasn’t in the rule. It was in a pipe.
I wrote about these two hooks once already, as one example among six in a piece about what survives from prompt engineering. This is the part that didn’t fit there: the actual mechanism underneath both, the full scripts, and the one that shipped broken before it shipped working.
What a hook actually is
Claude Code runs a hook by piping a JSON payload to it on stdin and reading its exit code back. PreToolUse fires before a tool call executes, matched against a tool name (or a pattern like Write|Edit) in settings.json. The payload carries the tool name and its input, so a hook watching Write gets the file path and the content about to be written; one watching Bash gets the command about to run.
For a PreToolUse hook enforcing a rule, the exit code is the core contract. 0 means continue, tool call goes ahead. 2 means block: the tool call never happens, and whatever the hook wrote to stderr goes back to the agent as the reason. Other hook events read exit codes differently, and Claude Code documents any code other than 0 or 2 as a non-blocking error rather than a deliberate signal, so for an enforcing PreToolUse hook, anything else is more likely a hook failure than a policy decision.
That’s the piece I skipped past last time: exit 2 carries a reason on stderr, delivered straight into the same context window that’s about to try again.
The design token guard
The Claude Code sessions post and the prompt-engineering one both mention this hook in passing. Here’s the whole thing.
The rule: no bare numbers in a SwiftUI modifier that has a design token equivalent. .padding(16) should be .padding(Spacing.md). The team knew the rule. The team also shipped .padding(16) about once a week, because a code review comment catches it a day later, after the PR is already open and somebody has to go back and fix it.
#!/bin/bash
# .claude/hooks/design-tokens.sh
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
[[ "$FILE" != *.swift ]] && exit 0
case "$TOOL" in
Write) CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // ""') ;;
Edit) CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // ""') ;;
*) exit 0 ;;
esac
PATTERN='\.(padding|frame\(width|frame\(height|font\(\.system\(size)\([0-9]'
if echo "$CONTENT" | grep -qE "$PATTERN"; then
echo "Magic number in a SwiftUI modifier. Use a design token instead of a literal." >&2
exit 2
fiTwo things the earlier snippet left out. First, Write and Edit carry the new content under different keys, content versus new_string, so a hook that only reads one silently does nothing on the other tool. Second, the check runs on the content about to be written, not the file on disk, which is the whole point: the write never happens if it matches, so there’s no commit to catch in review because there’s no diff to review.
The Swift 6 checker, and the bug in it
The second hook matches Bash, filters for commands containing git push, and blocks the push if any Swift file changed against origin/main has @unchecked Sendable without a TODO next to it. That’s a branch comparison, not a read of the actual push target, and it assumes this project’s workflow of one branch pushing to origin/main; a repo with a more complex branching model would need to parse the real git push command instead. @unchecked Sendable asserts a type’s Sendable conformance without compiler verification, moving the safety obligation onto the author; it’s sometimes the right call during a Swift 6 migration, but only as a tracked exception, not a silent one.
First version:
FILES=$(git diff --name-only origin/main... -- '*.swift')
for f in $FILES; do
grep -n '@unchecked Sendable' "$f" | while read -r line; do
if ! echo "$line" | grep -q 'TODO'; then
echo "$f: @unchecked Sendable without a TODO." >&2
exit 2
fi
done
doneThis is the version that ran for three weeks and never blocked a single push, including the one it should have. grep | while read puts the loop body in a subshell. exit 2 inside that subshell ends the subshell, not the script. The outer script kept going, fell off the end, and returned 0. Every push looked clean because the check that would have failed it was reporting its result to nobody.
Fixed by dropping the pipe:
while IFS= read -r -d '' f; do
while IFS= read -r line; do
if [[ "$line" != *TODO* ]]; then
printf '%s: @unchecked Sendable without a TODO. Push blocked.\n' "$f" >&2
exit 2
fi
done < <(grep -nF -- '@unchecked Sendable' "$f" || true)
done < <(git diff --name-only -z origin/main... -- '*.swift')Process substitution instead of a pipe keeps the while loop in the current shell, so exit 2 actually exits the script that Claude Code is reading the exit code from. That’s the fix for the bug that actually bit. Two smaller ones went with it, since the block was already being rewritten: grep exits 1 when it finds nothing, which is harmless here but would kill the script under set -e, so it’s chained with || true; and git diff -z plus read -d '' reads filenames NUL-delimited instead of splitting on whitespace, so a path with a space in it doesn’t quietly become two arguments. Neither had caused a failure yet. Fixing them here doesn’t make this a pattern to copy blindly into every hook, just a version with fewer sharp edges than the one that shipped broken for three weeks.
What a hook shouldn’t do
The antipattern isn’t a hook that’s too strict. It’s one that acts instead of stopping. A PreToolUse hook that deploys, deletes, or rewrites files on its own has turned a gate into side-effecting automation with no review step and no way to explain itself when it’s wrong. The two hooks above only ever do one of two things: let the write happen, or refuse it with a reason. Neither touches anything the agent didn’t already ask to touch.
That restraint is the whole design. A hook that blocks is auditable in one read of its stderr. A hook that acts needs its own review process, and at that point it’s not a guardrail anymore, it’s unreviewed code running on every tool call.
What actually held
Both hooks are configured for Write, Edit, and the Bash commands that match this project’s push workflow. The design token guard has blocked real writes outside of testing, more than once. The Swift 6 checker’s working version caught the same class of mistake its broken predecessor had missed for three weeks straight.
Neither hook makes the model better at SwiftUI or at Swift 6 concurrency. They don’t need to. The rule holds regardless of what the model was thinking when it wrote the line, which was the entire reason to stop asking nicely in the first place.
Key takeaways
- For an enforcing PreToolUse hook, the exit code is the rule, not the message.
0continues,2blocks and explains itself to the agent; anything else is more likely a hook failure than a policy call. WriteandEditaren’t the same event. A hook that only reads one tool’s input field silently stops covering the other.- A hook that never fires looks identical to one that’s broken. Test it with input you know should fail.
- Piping into
while readhides your exit code in a subshell. Use process substitution if the loop body needs to exit the script. - A hook should refuse, not act. The moment it deploys or deletes on its own, it stops being auditable in one read of stderr.
