From e03a30dfacec5a1ce4ea162cdee2ce1fc0dbe954 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 6 Sep 2026 02:45:31 +0200 Subject: [PATCH] fix(adapter-utils): close two leaks the scanner's reading set missed A single quote and an ANSI-C `$'` are what JSON serialization leaves alone, so unlike a double quote they name no layer. The reading set gave them depth 0 alone, so a serialized double-quoted segment adjacent to the closing quote was read as an escaped quote followed by a word-ending space, and its bytes stayed in the clear at every depth from 1 up. Bash joins them to the header word. Seed the tail after such an argument at every layer the unquoted reading set already carries and take the longest, which is what the union policy asks for. The body stays at depth 0 on purpose: these quotes delimit the same bytes at every layer, and reading the body deeper would take an ANSI-C escape for a plain backslash and close the value early. A backslash-newline is a line continuation. The token reader already named it, but the word scan returned at it as though it were a boundary, so the bytes on the next physical line stayed in the clear after an unquoted value and after a closed quoted argument. The shell removes the pair and joins the lines into one word, so the scan follows it now. Inside a quoted part the handling is unchanged. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../adapter-utils/src/command-redaction.ts | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index f89850d5f7..714d9fb9f1 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -366,9 +366,16 @@ function scanCommandQuotedBody( /** * Continue a shell word after its first segment. Segments concatenate, so a - * quoted part joins the same argument; whitespace, a metacharacter, a line + * quoted part joins the same argument; whitespace, a metacharacter, a bare line * break, or the serializer's own delimiter ends it. A quoted part with no * closer on the line is a truncated log line and ends the word at the line end. + * + * A backslash-newline is a line continuation, not a boundary: the shell removes + * it and joins the next physical line to the same word, so the scan follows it. + * A serializer writes that continuation either as a run of backslashes and a + * two-byte `\n` escape, which the token reader already carries as an ordinary + * escaped character, or as a run and a raw line break, which arrives here as a + * continuation token at that layer. */ function scanCommandWordTail(text: string, index: number, run: number): number { let cursor = index; @@ -379,8 +386,7 @@ function scanCommandWordTail(text: string, index: number, run: number): number { token.kind === "newline" || token.kind === "space" || token.kind === "metacharacter" || - token.kind === "serializerEnd" || - token.kind === "lineContinuation" + token.kind === "serializerEnd" ) { return token.start; } @@ -643,12 +649,30 @@ function redactCommandSecretHeaders( bounded.kind, ); if (body.closed) closerStart = body.end - (bounded.run + 1); - readings.push({ - end: body.closed - ? scanCommandWordTail(command, body.end, bounded.run) - : body.end, - closer: body.closed ? commandDelimiterCloser(bounded) : "", - }); + const closer = body.closed ? commandDelimiterCloser(bounded) : ""; + if (!body.closed) { + readings.push({ end: body.end, closer }); + } else { + // A bare double quote cannot appear inside serialized text and an + // escaped one carries its layer in its backslash run, so either one + // fixes the reading for the word that follows. A single quote and an + // ANSI-C `$'` are what a serializer leaves alone, so they fix nothing: + // the text after such an argument is shell text or any depth, and every + // one of those readings is scanned. Only the tail is seeded this way. + // The body is read at depth 0 because these quotes delimit the same + // bytes at every layer, and reading the body deeper would take an + // ANSI-C escape for a plain backslash and close the value early. + const tailRuns = + bounded.kind === "single" || bounded.kind === "ansi" + ? COMMAND_SECRET_HEADER_READING_RUNS + : [bounded.run]; + for (const run of tailRuns) { + readings.push({ + end: scanCommandWordTail(command, body.end, run), + closer, + }); + } + } } // The unquoted readings. With no delimiter to name a layer, every layer // from depth 0 to depth 4 is plausible and each is scanned. With an escaped