fix(hooks): escape all JSON control characters in ai_memory_json_string (#732)

JSON forbids a raw control character (U+0000..U+001F) inside a string, but the
escaper covered only backslash, quote, tab and CR. A replayed tool result
carrying an ANSI colour escape (0x1b) reached stdout unescaped, so Claude Code
rejected the whole managed-workstream SessionStart packet as invalid JSON and
the session started with no context; any handoff summary with a control byte
failed the same way. Escape every other control byte as \u00XX with a linear
per-byte gsub pass, reusing #737's BusyBox replacement-doubling probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm
This commit is contained in:
AkitaOnRails
2026-09-15 11:39:52 -03:00
co-authored by Claude Opus 4.8
parent e3b31d2eec
commit 2be13836a3
3 changed files with 40 additions and 0 deletions
+8
View File
@@ -74,6 +74,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
reported `Updated` and failed to dedup its own prior Antigravity/Cursor/Kimi
Code entries. It now matches both forms while still requiring the full
`hook --event … --agent … --server-url …` argv signature (#740).
- The POSIX shell hook bundle's `ai_memory_json_string` now escapes every JSON
control character (U+0000–U+001F) as `\u00XX`, not just backslash, quote, tab
and CR. A replayed tool result carrying an ANSI colour escape (0x1b) reached
stdout bare, so the managed-workstream SessionStart packet — and any handoff
whose summary held a control byte — was rejected as invalid JSON and the
resuming session started with no context. The escaping is linear and reuses
the same BusyBox replacement-doubling probe as the four existing escapes
(#732).
## [2.2.1] - 2026-09-12
+22
View File
@@ -548,9 +548,26 @@ ai_memory_json_string() {
BEGIN {
probe = "X"; gsub(/X/, "\\\\", probe)
if (length(probe) == 1) {
busybox = 1
BS = "\\\\\\\\"; QT = "\\\\\""; TB = "\\\\t"; CR = "\\\\r"
UP = "\\\\u"
} else {
busybox = 0
BS = "\\\\"; QT = "\\\""; TB = "\\t"; CR = "\\r"
UP = "\\u"
}
# JSON forbids a raw control character (U+0000..U+001F) inside a
# string, but the four gsubs above only cover backslash, quote, tab
# and CR — so a replayed tool result carrying e.g. an ANSI colour
# escape (0x1b) reached stdout unescaped and Claude Code rejected
# the whole SessionStart packet as invalid JSON (#732). Build a
# \u00XX escape for every other control byte once; newline (0x0a) is
# never inside a record, it is the record separator handled below.
nc = 0
for (c = 1; c < 32; c++) {
if (c == 9 || c == 10 || c == 13) continue
cc[++nc] = sprintf("%c", c)
cr[nc] = sprintf("%s%04x", UP, c)
}
printf "\""
}
@@ -559,6 +576,11 @@ ai_memory_json_string() {
gsub(/"/, QT)
gsub(/\t/, TB)
gsub(/\r/, CR)
# After the backslash gsub, so the backslashes these introduce are
# not doubled. Each control byte is a literal (none is a regex
# metacharacter), and the pass is linear, not the per-char loop #727
# replaced.
for (k = 1; k <= nc; k++) gsub(cc[k], cr[k])
printf "%s%s", sep, $0
sep = "\\n"
}
+10
View File
@@ -169,6 +169,16 @@ next line'
assert_eq "json_string escapes text" '"quoted \"thing\" \\ path\nnext line"' \
"$(printf '%s' "$JSON_INPUT" | ai_memory_json_string)"
# A raw control byte (JSON forbids U+0000..U+001F inside a string) must become
# a \u00XX escape, not reach stdout bare -- a replayed ANSI-coloured tool
# result otherwise made the whole SessionStart packet invalid JSON (#732).
CTRL_OUT="$(printf 'a\033[0mb' | ai_memory_json_string)"
case "$CTRL_OUT" in
*'\u001b'*) CTRL_ESCAPED=yes ;;
*) CTRL_ESCAPED=no ;;
esac
assert_eq "json_string escapes a control byte as a \u escape" "yes" "$CTRL_ESCAPED"
# --- marker_qs --------------------------------------------------------
QS=$(ai_memory_marker_qs "$TMP/a/b/c")
assert_eq "marker_qs single key" "&cwd=$(ai_memory_url_encode "$TMP/a/b/c")&workspace=deep" "$QS"