Skip to content

on-stop.sh: byte-level truncation splits multibyte UTF-8, Stop hook fails Claude Code JSON validation #70

Description

@jwnichols3

Summary

scripts/on-stop.sh (plugin v2.1.0) truncates the extracted query/response with bash substring expansion:

QUERY="${QUERY:0:197}..."
RESPONSE="${RESPONSE:0:197}..."

Bash slices by byte, not codepoint, when the hook runs without a UTF-8 locale (Claude Code hook subprocesses commonly inherit C/POSIX). When position 197 lands inside a multibyte character - em-dashes, arrows, box-drawing characters like that Claude Code transcripts are full of - the truncated string ends with an invalid UTF-8 fragment. That fragment goes through jq --arg into the notification payload, and Claude Code rejects the hook's stdout:

Ran 6 stop hooks
  ⎿  Stop hook error: JSON validation failed

It is intermittent by nature: it only fires when byte 197 happens to split a character, so it looks flaky and is painful to attribute (Claude Code does not say which hook failed - see anthropics/claude-code#21992 for that class).

Reproduce

# 1. Craft a transcript whose last user message has a 3-byte char straddling byte 197
python3 - <<'EOF'
import json
q = 'a'*195 + '⎿ tail of the question'      # U+23BF = 3 bytes; bytes 196-198
lines = [
    {'type':'user','message':{'content': q}},
    {'type':'assistant','message':{'content':[{'type':'text','text': 'b'*195 + '— long response text'}]}},
]
open('/tmp/repro-transcript.jsonl','w').write('\n'.join(json.dumps(l) for l in lines))
EOF

# 2. Demonstrate the corruption the hook's truncation produces under a C locale
Q=$(jq -rs '[.[] | select(.type=="user")] | last | .message.content' /tmp/repro-transcript.jsonl)
LC_ALL=C bash -c 'Q="'"$Q"'"; printf "%s" "${Q:0:197}"' | python3 -c '
import sys
sys.stdin.buffer.read().decode("utf-8")'   # -> UnicodeDecodeError: invalid continuation byte

Running the full on-stop.sh against such a transcript with LC_ALL=C (and TERM_PROGRAM=WarpTerminal + a structured-notifications-capable Warp env) emits a payload containing the broken fragment, which fails Claude Code's stop-hook stdout schema validation.

Fix

Truncate codepoint-safely. jq is already a hard dependency of the script, so the smallest change is:

# Truncate for notification display (codepoint-safe; bash ${VAR:0:N} slices
# by byte under non-UTF-8 locales and can split a multibyte character).
if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then
    QUERY="$(printf '%s' "$QUERY" | jq -Rrs '.[0:197] + "..."')"
fi
if [ -n "$RESPONSE" ] && [ ${#RESPONSE} -gt 200 ]; then
    RESPONSE="$(printf '%s' "$RESPONSE" | jq -Rrs '.[0:197] + "..."')"
fi

Verified locally (patched plugin cache): valid JSON output across repeated runs under both UTF-8 and C locales, including the crafted boundary-splitting transcript above; the per-turn "JSON validation failed" error disappeared.

An equivalent guard likely belongs in legacy/on-stop.sh too if it shares the truncation idiom.

Environment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions