Uh oh!
There was an error while loading. Please reload this page.
Escape captured control sequences where a query result is rendered for a person (#752) - #760
Conversation
…r a person (#752) Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Aug 14, 2026
LLP 0224 was a collision; renumbered to 0225This PR minted Neither PR's CI catches this: the Resolution: this PR renumbers to LLP 0225, by push order (#759 pushed Pushed as Nothing about the design changed, and the review that follows will read the head |
philcunliffe
commented
Aug 14, 2026
neutral review - round 1Head reviewed: Four findings, all minor or nit. No security gap, no mangled legitimate string, The two things I most wanted checked both came back clean, and were checked the
Its completeness argument is the part worth keeping: every terminal escape must VERDICT: findings All four findings are minor or nit. No security gap, no mangled legitimate string, no 1. minor — The text reads: "Unit tests, each shown failing against the pre-fix source before being kept: I checked out Why it matters: the Verification section of an Accepted LLP is the record a later reader trusts when deciding whether a test is load-bearing. As written it asserts a property those two tests do not have, and the next person to touch this file has no way to tell which of the listed tests actually discriminate. (The PR body reportedly gets this right by calling out two tests that "pass pre-fix by design"; the LLP does not, and the LLP is the artefact that survives.) The tests themselves are fine and are not vacuous — I proved both by mutation, see below. This is a doc-accuracy fix only. Exact fix: replace lines 163-166 with a sentence that separates the two roles, e.g. 2. nit — * @param{string}outputPath* @param{{columns: string[],rows: Record<string,unknown>[]}}full*Thereceiptisahuman-facingrenderinitsownright,whatever*`--format`thefilegot,soitspreviewisescapedtoo. ...
** @refLLP0225#decision [constrained-by]: thereceiptisahumanrender,soitescapes** @param{string}contentthealready-renderedfilecontent(sizedforthereceipt)In JSDoc, everything between a Why it matters: this is the one place in the diff where the rationale-to-construct attachment CLAUDE.md asks for is visibly wrong, and Exact fix: move the paragraph and the 3. nit — truncate(escapeForDisplay(hasModelLabel(r) ? String(r.model).trim() : '(model not recorded)'),MAX_MODEL_WIDTH),
...
truncate(cell(r.tool_name),MAX_MODEL_WIDTH),
This is cosmetic only and I want to be clear it is not a security hole: the truncated tail is still pure printable ASCII, cannot reintroduce a control character, and the trailing Why it matters: it is the one place the PR's own stated invariant ("the output is pure ASCII, one column per character") is applied in the wrong order relative to a width clamp, and a reader debugging a mangled Exact fix: clip first, escape second, matching escapeForDisplay(truncate(hasModelLabel(r) ? String(r.model).trim() : '(model not recorded)',MAX_MODEL_WIDTH)),and for the tool column, replace 4. nit — The LLP records: "A newline inside a I confirmed there is no conflict and no double-handling: Why it matters: Exact fix: extend the existing bullet to "A newline inside a The escape class, attacked from both sidesWhat I tried to sneak through raw. 36 vectors, each rendered through the real 7-bit Result: 0 leaks, 0 forged rows, in all three renders. The zero-row early returns are covered too, which is easy to miss ( On the C1 claim specifically: I verified it end-to-end. Completeness argument for the class: every terminal escape sequence must begin with Gaps I looked for and judged out of scope rather than findings: astral tag characters U+E0000-E007F are default-ignorable and not in either class, but they are load-bearing in tag-sequence emoji and cannot drive a terminal; the label plane does not cover them either, so this is pre-existing and consistent. Confusables are excluded by an argument the LLP inherits from LLP 0164 and I agree with. What I tried to get wrongly mangled. 21 legitimate strings, each asserted to survive verbatim in all four formats ( Result: 0 mangled. Confirmed again through the real CLI: I then mutated the class to widen it ( Also checked, cleanThe The PR's own test at Column widths, clipping and truncated escapes.
The MCP path. Who parses
Tests re-derived, including mutants. Full LLP 0225 itself. Number free: no Conventions. No em dash (U+2014) in any of the eight files. No trailing semicolons in the new JavaScript. No On the untested-terminal caveat. The author states they could not test against a real terminal emulator, and the LLP repeats it under "Not verified". I judge nothing material to depend on it. The safety argument does not rest on observing a terminal: it rests on the output being pure printable ASCII, which I verified exhaustively over the whole BMP ( |
- LLP 0225 Verification: separate the six tests that discriminate the fix from the two that pass pre-fix by construction and guard preserved behaviour, confirmed by re-running the three new test files against the pre-fix sources and by mutation. - format.js: move the receipt rationale and its @ref out of the `full` param's description and into the function description, so the @PARAM list is contiguous again. - overview.js: clip before escaping for the model and tool columns, matching format.js's order, so a `\uXXXX` escape can no longer be cut in half at MAX_MODEL_WIDTH. cell() gains an optional width argument that truncates the raw value before escapeForDisplay. - LLP 0225 Consequences: record the markdown newline behaviour change alongside the table one. - Add a test pinning table column width to the escaped header rather than the raw column name (the one surviving mutant from review).
philcunliffe
commented
Aug 14, 2026
neutral review - round 2 (final)Head reviewed: Three findings, none ship-blocking. All four round-1 findings are fixed, and the Finding 1 is the recursive one and worth reading: the round-2 commit rewrote the LLP Review complete. Worktree and main repo are clean; scratch files removed. VERDICT: findings Three findings: one minor, two nits. None are ship-blocking. All four round-1 findings are genuinely fixed, the round-2 rendering change is behaviourally correct, and nothing round 1 cleared regressed. Head reviewed: 1. minor — The section now reads "Unit tests. Six were shown failing against the pre-fix source before being kept: I re-derived the split myself rather than trusting the fixer: I built a The six items the sentence names each map to a test that really does fail pre-fix, and the two it calls preserved-behaviour guards really do pass pre-fix, so the split's substance is correct. The omission is the new test 8 alone. Why it matters: this is the same class of inaccuracy round-1 finding 1 raised, in the same paragraph, reintroduced by the round-2 commit that rewrote it. The Verification section reads as an exhaustive enumeration of that file, and the omitted test is the most interesting one (attacker-controlled column name, the defence-in-depth branch that round 1 found untested). Exact fix: change 2. nit — I mutated both hunks back to the round-1 ordering ( The fix is correct (verified independently, see below), but the invariant the new JSDoc at Why it matters: the fixer did add a pin for the mutant round 1 reported, so the standard the PR sets for itself is that a found defect gets a test. The defect it fixed in round 2 did not get one. Exact fix: add to test('overview clips the raw value before escaping, so no escape is cut in half',()=>{constESC='\u001b'constout=renderProviderMix([{provider: 'p',model: 'm'.repeat(28)+ESC+'zzzz',input_tokens: 1,cached_tokens: 1,output_tokens: 1}],false)assert.equal(out.includes(ESC),false)// No partial `\uXXXX`: every backslash-u carries all four hex digits.assert.equal(/\\u[0-9a-f]{0,3}(?![0-9a-f])/.test(out),false)})and the same shape against 3. nit — The new JSDoc says Alignment is correct - both data rows measure 230 characters and every later column starts at the same offset, because This is the trade round 1 named explicitly when it proposed the fix, and it is the right trade: it only fires on data that already contains control characters (escaping is a no-op on legitimate text, so real rows are unaffected - confirmed over the whole legitimate corpus), and a 175-column row is strictly better than a half-cut escape. So this is a documentation gap, not a defect. Exact fix (doc-only): extend the Round-1 findings, re-derived
Also checked, cleanThe process near-miss — the tree is clean of it. Attack corpus, replayed against this head. 27 vectors (7-bit Legitimate corpus, replayed. 21 strings (NFC and NFD accents, CJK, Hebrew and Arabic RTL letters, an Arabic/Latin mix, a four-person ZWJ family emoji, a skin-tone ZWJ profession emoji, a VS16 heart-on-fire, a regional-indicator flag, a tag-sequence flag, a Windows path, a backslash-heavy regex, a JSON blob with a literal
Tests and gates, all run in this worktree after a fresh Conventions, over all eight changed files. No em dash (U+2014) anywhere. No Non-display use of |
philcunliffe
commented
Aug 14, 2026
neutral triage - shipReview budget exhausted with three residual findings. All three are preferences. Finding 2 was the one to think hardest about, and triage re-derived both halves"Correct code, no test" is the shape that reaches blocker status when the unguarded
So the security property survives the mutation and only the cosmetic spelling Finding 3: a wrapped table versus a bounded row that liesTriage built the hostile row and looked at it. A model of 30 raw ESC renders as a Weighed against what that same row did before this PR: it repainted the screen, hid Finding 1The Verification enumeration is stale by one test, in the very commit that rewrote it The baseline that frames all threeBefore this PR, every captured string column was a live terminal-injection vector on Verified at head |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#752.
hyp query sqlwrote captured bytes straight toprocess.stdout, which is notwrapped the way
dispatchwraps stderr. Every string column of every dataset isverbatim captured text, so an
ESCin a prompt, a log body, an HTTP headervalue or a filename reached the operator's terminal and was interpreted.
Design record: LLP 0224 (Decision, Accepted).
The option taken, and why the others were rejected
Taken: sanitize in
formatCellfor the human-facing formats, escaping ratherthan stripping.
tableandmarkdownescape;jsonandjsonlstaybyte-exact, so the data stays extractable and pipelines keep exact bytes.
Escaping rather than stripping keeps the row honest about what was captured: a
rendered cell is the payload the operator asked to see, so silently dropping
bytes turns a query into a lie about the capture.
Rejected: gate on
process.stdout.isTTY. It makes one command printdifferent bytes depending on whether it is piped, so what an operator saw and
what they saved to a file disagree, and a bug reproduces differently under
redirection. The format flag is already in the operator's hand and already
decides every other rendering question; a pipeline that wants exact bytes asks
for
--format jsonland gets them on a TTY too. No assertion in this PR touchesisTTY, and none needs to.Rejected: do nothing and document the output as untrusted (the
caton abinary file argument).
cathas no idea what its bytes are. This rendererknows every cell it is laying out and has already clipped each one for the
context budget; a renderer that lays output out in aligned columns has already
promised the columns mean something.
sanitizeLabel: neither reused nor duplicated, decomposedsanitizeLabelis the wrong function for a cell (it truncates, and returnsundefinedfor empty), but its character class is exactly the right vocabulary.Copying that class would have created the two-divergent-notions problem the
issue warns about.
So the class in
src/core/util/json_util.jsis now composed from three namedgroups, and both policies are built from those groups in that one file:
sanitizeLabelescapeForDisplayTERMINAL_CONTROL_CHARSBIDI_FORMATTING_CHARSINVISIBLE_FORMATTING_CHARSsanitizeLabel's behaviour is unchanged: the recomposed class is the same setof code points as the literal it replaces, and
sanitizeLabel strips exactly the code points it stripped before LLP 0224asserts that over all of the BMPagainst a held copy of the original regex.
The exact character class escaped, and the reasoning
Escaped, with the spelling used:
\^@-\^_(C0),\u007F(DEL),\^@-\^_(C1)\n\r\t, else\uXXXXJSON.stringifyescapes C0 but leaves\^[(8-bit CSI) raw\u2028,\u2029\uXXXX\u061C,\u200E-\u200F,\u202A-\u202E,\u2066-\u2069\uXXXXDeliberately left alone:
BOM, soft hyphen, variation selectors). Stripped from a label because a
label is a map key and two keys that render identically dilute the entrypoint
tracker's 32-entry eviction cap. A query cell is not a key, so nothing
downstream is diluted, while ZWJ and the variation selectors are load-bearing
inside ordinary emoji: a family emoji is a ZWJ sequence and
U+FE0Fis whatcolours a heart. Escaping them would visibly corrupt legitimate captured prose
on a large fraction of real rows to defend against a character that cannot
repaint anything.
Windows paths, regexes and JSON blobs. Disambiguating a literal two-character
\nfrom an escaped newline would mangle all of them; the ambiguity iscosmetic and neither spelling can move a cursor.
bounds what a value does, not what it looks like.
Behaviour change worth naming: a newline inside a
tablecell now prints as\ninstead of breaking the row. That is visible for multi-line prose, and itis the point: a newline in a cell is how a captured value forges a row.
What changed
src/core/util/json_util.js: the three named groups, plusescapeForDisplaybesidesanitizeLabel(exported throughsrc/core/util/index.jsashypaware/core/util).src/core/query/format.js:formatCellescapes its finished text (not onlythe string branch, because the object branch goes through
JSON.stringify,which passes C1 and bidi through). Table headers are escaped as defence in
depth. Widths are measured on the escaped text, which is what is padded and
printed.
mdEscapeloses its now-dead newline replacement. The--outputspill receipt escapes its preview line by line (the newlines between preview
rows are structure the receipt produced, not captured bytes) while the file it
wrote keeps every byte.
src/core/query/overview.js: folded in, because it is the same query plane,the same captured columns, and one shared
cell()helper.provider,dateand
tool_namego throughcell();modelandrepo_rootare the two sitesthat did not. The escape sits on each captured value rather than on the
assembled row because this block paints its own bars and headings - a
sweep over the finished table would strip the colour along with the attack.
The test asserts exactly that: with
color: true, every rawESCleft in theoutput matches
^\^[\[[0-9;]*m.hyp vector searchis fixed for free: it renders through the samerenderResult.Surface survey (the issue asked for this and did not do it)
Surveyed for captured strings reaching a terminal without
sanitizeLabelorJSON.stringify. Nothing outsidesrc/core/query/is fixed here; each of thesewants its own issue, because they are label-plane surfaces where
strip-versus-escape is a separate argument.
Note
JSON.stringifyis not equivalent to either policy for a terminal: itescapes C0 but passes DEL/C1 (including 8-bit CSI
\^[), bidi overrides andzero-width characters through untouched. Several
--jsonpaths below rely on it.Worst uncovered surface:
hyp graph neighbors(
hypaware-core/plugins-workspace/context-graph/src/verb.js:169,197,202,206,209).display()(:228) anddisambiguator()(:242) apply a 48-character clampand no stripping. Labels come straight off
ai_gateway_messagesviaai-gateway-graph/src/graph_contract.js:client_name(:90),model(
:102),tool_name(:115), afile_pathbasename taken from inside atool_useblock (:133,:515, fully model-controlled),git_remote(:146),a Bash-parsed
program(:180), skill names (:206+).hyp status/hyp daemon status, all read back out of$HYP_HOME/run/status.json, a file this build may not have written. Raw:gateway upstream names (
src/core/daemon/status.js:323,341,347,859, nosanitize and no count cap),
listen_fallback_from(:823,827,828),daemon.state/daemon.mode/daemon.error(
src/core/commands/status.js:591,593,594-erroris V8'sJSON.parsemessage, which quotes a verbatim excerpt of
status.json),sources[]/sinks[](:330,339), the client probe error (:364, quotes~/.claude/settings.jsonor~/.codex/config.toml), remote-config etags fromthe
ETagHTTP response header (:464,466,469,472anddaemon/status.js:1041), reconciler request keys (:503,506), layered-configdrops (
:452).hyp daemon statusrepeats this atsrc/core/commands/daemon.js:87-106, including a source's own error string.Note: commits
a535050/2b799c1/33c3934(issues #680 / #681) addressexactly the
status.jsonsubset above but are not merged intomaster, sonone of it is present on
c483c1a.Other captured values reaching a stream raw:
hyp purgeprints one cachedcwdper line (src/core/commands/purge.js:166,175);hyp policy showprintspolicy-store directories and source names
(
src/core/commands/policy.js:483,744,748);hyp sessionprints a session idread out of a Codex rollout header and the rollout filename
(
ai-gateway/src/session_command.js:332,366,413-419,503);hyp backfill planprints discovered transcript filenames and scan errors
(
src/core/commands/backfill.js:1108,1158); the walkthrough echoes attach andbackfill adapter errors that quote client config files
(
src/core/cli/walkthrough.js:1621,1961); the Claude/Codex/OpenClaw attachadapters echo previous config values read from files HypAware does not own
(
claude/src/index.js:506,509,codex/src/index.js:452,461,466,openclaw/src/attach.js:533-543);hyp report listand the remote commandsecho HTTP response body fields (
src/core/cli/report_commands.js:268,526-535,src/core/cli/remote_commands.js:692,825,834);hyp pluginechoes fetchedmanifest/registry fields (
src/core/commands/plugin.js:264,287-297,335). Thedev logger mirror (
src/core/observability/logger.js:101) puts capturedattributes through
JSON.stringifyonly, and the gateway logs the client's rawHTTP request path through it (
ai-gateway/src/source.js:274-283).Currently sanitized anywhere in the product: exactly one field family,
recent_entrypoints[].entrypoint/.client_name, cleaned at record(
ai-gateway/src/entrypoint_activity.js:64,66) and again at read(
src/core/daemon/status.js:412,418).Discrimination evidence
Every test was watched failing before being kept. Reverts are
git checkout c483c1a -- <file>; mutants are hand edits to the fix.src/core/query/format.jsreverted toc483c1aquery-format-escaping: 6 of 8 fail (1 table, 2 markdown, 3 newline, 4 tab/CR, 7 alignment, 8 receipt). 5 (json/jsonl byte-exact) and 6 (non-ASCII survives) pass, as they must: they guard preserved behavioursrc/core/query/overview.jsreverted toc483c1aquery-overview: 1 of 63 fails (renderOverview escapes captured columns and keeps its own colour)src/core/util/json_util.js+index.jsreverted toc483c1autil-json-utilfails to load at all (noescapeForDisplayexport): 1 file-level failure. Finer mutants belowescapeForDisplayreturns its input unchangedescapeForDisplaystrips instead of escapingINVISIBLE_FORMATTING_CHARSto the display classordinary non-ASCII text is not touched by either human formatandescapeForDisplay escapes bidi formatting and leaves zero-width formatting alone. This is the guard on the emoji decision\u2028-\u2029fromTERMINAL_CONTROL_CHARSsanitizeLabel strips exactly the code points it stripped before LLP 0224(the BMP equivalence oracle) andescapeForDisplay replaces control characters with visible escapes. This is the guard on the class refactorjsonandjsonltoojson and jsonl stay byte-exactand the receipt test. This is the guard on the byte-exactness half of the decisionChecks
walkthrough_picker_to_first_queryis not run: it fails onmasterfor anunrelated reason tracked in #750.
Not verified
\^[isinert and a raw
ESCsequence is not rests on the escape output being pureprintable ASCII, not on an observed terminal.
are measured in UTF-16 code units, so a CJK or emoji cell was misaligned
before this change and is misaligned by the same amount after. This PR only
guarantees that an escaped cell aligns correctly, which it does because the
escape output is one column per character.
listed line was traced to an origin by reading, not by observing a value
arrive.
🤖 Generated with Claude Code