Escape control characters in client-sent names in prose - #63
Conversation
The unknown: :log warn line, the monitor-mode warn line and
InvalidParameters#message interpolated request key names raw, so a key
containing a newline wrote a second, forged log entry. A name containing
a control character (\p{Cc}) or U+2028/U+2029 is now printed quoted and
escaped; ordinary names print byte-for-byte as before. Truncation cuts
between whole escapes. details and the instrumentation payload still
carry every name as sent.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
Review of #63. A name is now quoted when it contains a quote anywhere or the list separator ", ", not only a leading quote, so it cannot pass for two names or fake the "and N more" count. Bidi embedding, override and isolate characters join PROSE_UNSAFE. Only the client-sent name is escaped; the developer's message is passed as a separate suffix and printed as is. A name is judged by the part the truncation shows, and only a bounded prefix of it is read. Every item is emitted as UTF-8, so legacy-encoded bytes are not written raw and names of mixed encodings join. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Review addressed in 9e27f54 (merged origin/master first; CHANGELOG kept master's entries first). All six points: quote on 🤖 Generated with Claude Code |
Regression pass on #63. A name that begins "and N more" is quoted, as is one holding a quote lookalike (fullwidth quote, Pi/Pf) or a fullwidth, small or ideographic comma. PROSE_UNSAFE is now the property set Cc, Cf, Zl, Zp and every Zs but U+0020, so zero-width and format characters and no-break spaces are escaped. A quoted name that fits the limit is no longer cut to make room for its suffix. A legacy-encoded key is transcoded per character, so only unmappable bytes print as \xNN. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Regression pass addressed in 139d3b9: the 🤖 Generated with Claude Code |
Regression pass on #63. The overflow-phrase check ("and N more") is now case-insensitive, since "And"/"AND" reads identically once rendered. The quote-lookalike check now uses Unicode's own Quotation_Mark property instead of enumerating Pi/Pf: it also covers the CJK corner brackets U+300C/U+300D, real quotation marks in Japanese/Chinese text that fall in Ps/Pe rather than Pi/Pf. U+FE51, the small-form ideographic comma, joins the comma set. CHANGELOG notes plainly that this guard defeats case and punctuation lookalikes, not Unicode homoglyph substitution. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
|
Final regression round addressed in 7890786 (rebased on origin/master; CHANGELOG kept master's entries first). Two concrete gaps closed: the 🤖 Generated with Claude Code |
CHANGELOG only: keep master's entries ahead of this PR's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CHANGELOG conflict: kept master's entries ahead of this PR's. Integration bug found while merging: #61's Coercion.reportable_text (scrub an undeclared key to U+FFFD for JSON safety) and #63's richer per-byte prose escaping (\xNN for an unmappable byte, char-by-char transcoding) both touched the same undeclared-key path, and #61 ran first — so by the time #63's escaping saw the key, its original bytes were already gone, replaced by U+FFFD. Two of #63's own specs caught this on merge (the legacy-key transcoding spec and the invalid-UTF-8 spec), and a third, pre-existing #61 spec asserted the now-stale scrubbed-U+FFFD text in the :log line. Fixed by separating the two purposes cleanly: - `param:` in `details`/instrumentation/problem+json `errors` stays Coercion.reportable_text — valid UTF-8, safe for to_json, unchanged. - Prose (the :log line and InvalidParameters#message alike) now reads the RAW key, converted only with permittable_prose_utf8 (encoding-tag-safe, byte-preserving — the same conversion #63 already uses), so #63's escaper can do its own richer per-byte transcoding. permittable_unknown_key_violation stashes this raw-based prose text in a side table, by identity, alongside the existing violation-identity tracking; permittable_violation_summary prefers it over v[:param] when present. Updated the one stale #61 spec to assert the new, more informative output (caf\xC3 instead of a bare U+FFFD) rather than weakening the fix, and added a new spec pinning the split: the exception message shows the rich transcoding, details/problem+json stay the plainer JSON-safe form for the same violation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The bug
The
unknown: :logwarn line, the monitor-mode warn line andInvalidParameters#messageall interpolate the names of the keys a request sent, raw. 0.8.0's prose bounds cap how many names are listed (10) and how long each is (120 chars), but they escape nothing. So one key:That second line is not from the app. Anyone reading or grepping the log sees a fake
ERRORentry.The fix
permittable_prose_itemnow quotes and escapes a name only when it needs it:InvalidParameters#messageis also what API clients read: the envelope'smessageand the problem+jsondetail. A client that sent such a key sees the escaped rendering there too. The CHANGELOG says so.Which characters are escaped (
PROSE_UNSAFE)/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Zs}&&[^ ]]/, one Unicode property per reason:Ccis C0, DEL and C1. C1 is in because U+0085 is NEL (a line break to many readers) and U+009B is the 8-bit CSI that starts a terminal escape.Zl/Zp(U+2028/U+2029): JSON-lines and JavaScript readers split a line on them.Cfcovers two groups:Zsexcept U+0020, sox,<NBSP>and 49990 morecannot pass for the,separator.When a name is quoted without escaping (
PROSE_AMBIGUOUS)These characters print as they are. Only the quoting marks the name:
", the fullwidth", or anyPi/Pfmark (curly quotes, guillemets). Otherwisex, "evil\nE, … forged"sent with a literal backslash would print like two names, one with a real newline.,or a fullwidth, small or ideographic comma (,﹐、). Otherwise the name could pass for two names.and N more. Otherwise keysbandand 49990 morewould print asb, and 49990 more, a fake overflow count. This applies in all three prose outputs.Every check looks only at the part of the name that the 120-char truncation shows. A character past the cut is never printed, so it does not quote an otherwise ordinary long name.
Decision I'd most like reviewed: the ellipsis may overrun
The rule is that a quoted name is never cut when it fits the limit on its own. When name + suffix is too long, the suffix is cut to whatever room is left. For a quoted name of 118–120 chars, nothing is left, and the
...that marks the dropped suffix then runs up to 3 characters past 120.The alternatives were worse:
The overrun is bounded and happens only on this path. A quoted name that does not fit is cut between whole escapes, to exactly 120 with the
...outside the closing quote. The spec pins every boundary, by quoted-name length: 110 (the item is exactly 120), 111, 117, 118, 120 and 121.Other decisions
[param, suffix]. The suffix is the developer'smessage:or the(code), and it is printed as is. Ordinary items truncate exactly as before.\n\r\tuse the short form. Other characters are\uXXXX, and\u{XXXXX}beyond the BMP (the Cf tag characters). I did not useString#dump/#inspect:dumpturns every non-ASCII character into\u, andinspectdepends onEncoding.default_external.Encoding::Converter#primitive_convert:éprints asé.0x810x8D0x8F0x900x9D) is kept as an invalid byte, which the escaper shows as\xNN.PROSE_SCAN_LIMIT(4 × 120) characters of a name are converted or scanned."#{path}.#{key}"is left to Turn three client inputs that raised into 422 violations #61, as agreed. The CHANGELOG claim is limited to the join and points at Turn three client inputs that raised into 422 violations #61.Round 3: an honest limit
This guard defeats literal-text lookalikes: case variants of "and N more", and characters that visually stand in for a quote or comma. It does not attempt general Unicode confusable detection — a Cyrillic
а(U+0430) substituted for Latinain "and N more" still passes unescaped. The CHANGELOG says so plainly, so nobody mistakes this for a complete guarantee. Two concrete gaps were closed instead:\Aand \p{Nd}+ morematched only lowercase;"And 49990 more"printed unescaped and was visually indistinguishable from the real overflow suffix. The match is now\A(?i:and \p{Nd}+ more), scoped to just that alternative so the rest of the pattern stays case-sensitive (nothing else in it is a letter).["\u{FF02}\p{Pi}\p{Pf}]— but the CJK corner brackets「/」(U+300C/U+300D), real quotation marks in Japanese and Chinese text, are punctuation category Ps/Pe, not Pi/Pf, so they were missed entirely. Ruby/Onigmo supports the broader, correct fix:\p{Quotation_Mark}, a binary Unicode property that already covers plain", fullwidth", every curly quote and guillemet, and the CJK corner brackets — so it replaces the whole enumerated set. (U+3008/U+3009 angle brackets are not inQuotation_Mark— Unicode itself doesn't classify them as quotation marks, and they're used in CJK for titles more than quoting, so I left them out rather than guessing.) Separately, U+FE51 (the small-form ideographic comma, sibling to U+3001 the way U+FE50 is the plain comma's) was missing from the comma set and is now added.Verification
:logwarn line, the exception message and the monitor warn lineband 4 morestays rawmessage:left alonedetailsand the instrumentation payload carrying the raw nameReview rounds
Round 1 (9e27f54): quote on
"anywhere and,; UTF-8 output for every item; escape only the name, not the developer message; judge the visible prefix and read a bounded one; bidi characters; CHANGELOG notes the client-facingmessage/detail.Round 2 (139d3b9):
and N more.Zsexcept U+0020; quote on fullwidth, small and ideographic commas,", andPi/Pf.\xNN.Cf.Round 3 (7890786, this update):
\p{Quotation_Mark}(covers the CJK corner brackets too); add U+FE51 to the comma set; state the homoglyph limit in the CHANGELOG.🤖 Generated with Claude Code