DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

I Fixed Unbounded Shell Output in an Open Source Agent. My First Draft Would Have Corrupted Text.

A few weeks back I picked up google-gemini/gemini-cli issue #28090: the shell tool was forwarding a command's entire stdout/stderr straight into the model's context, with no cap unless you opted into an LLM-based summarization step. Run one noisy build command and you'd hand the model tens of thousands of tokens of log spam it never asked for. The fix sounded trivial: cap the output before it goes into llmContent. I had a one-liner in my head before I'd even opened the file.

That one-liner is exactly the kind of "obviously correct" fix that ships bugs.

The one-liner

The naive version looks like this:

const MAX = 32 * 1024; // 32 KiB
function truncate(output) {
  if (output.length <= MAX) return output;
  return output.slice(0, MAX) + '\n...[truncated]...\n' + output.slice(-MAX);
}
Enter fullscreen mode Exit fullscreen mode

It compiles. It passes a quick manual test with a big ASCII log file. It looks done. I almost committed it as-is before writing the actual test suite.

The problem is what .slice() is slicing. JavaScript strings are sequences of UTF-16 code units, not bytes and not Unicode codepoints. Most characters in typical shell output (letters, digits, punctuation) are one code unit each, so .slice() looks safe in casual testing. But the moment real-world command output contains anything outside the Basic Multilingual Plane — an emoji in a commit message, certain box-drawing/progress-bar characters some CLIs use, non-Latin filenames — that character is represented as a surrogate pair: two 16-bit code units that only mean something together. Slice between them and you don't get an error. You get one dangling unpaired surrogate on each side of the cut, silently baked into the string that gets sent to the model.

No exception. No lint warning. JSON.stringify on the payload can even throw later, in a completely unrelated part of the request pipeline, for a reason that has nothing to do with where the bug actually is. Or worse: it doesn't throw, and the model just receives a slightly mangled character at the exact truncation boundary and you never find out, because nobody's diffing byte-for-byte what a shell command "should" have output.

This is the shape of bug that's genuinely hard to catch by eye. It only shows up with specific input (non-BMP characters landing exactly at your truncation offset), it doesn't crash where it happens, and a shallow test with ASCII-only fixtures — which is what most people, AI-assisted or not, reach for first — will pass cleanly and tell you nothing.

What actually shipped

The real fix (truncateLlmOutput in packages/core/src/tools/shell.ts, MAX_LLM_OUTPUT_BYTES = 32 * 1024) does the head+tail bound in bytes, not code units, and walks the boundary to land on a valid codepoint rather than cutting mid-character:

const MAX_LLM_OUTPUT_BYTES = 32 * 1024;

function truncateLlmOutput(output) {
  const encoder = new TextEncoder();
  const bytes = encoder.encode(output);
  if (bytes.length <= MAX_LLM_OUTPUT_BYTES * 2) return output;

  const headBytes = safeSliceToCodepointBoundary(bytes, MAX_LLM_OUTPUT_BYTES, 'start');
  const tailBytes = safeSliceToCodepointBoundary(bytes, MAX_LLM_OUTPUT_BYTES, 'end');
  const decoder = new TextDecoder();
  return (
    decoder.decode(headBytes) +
    `\n... [truncated ${bytes.length - headBytes.length - tailBytes.length} bytes] ...\n` +
    decoder.decode(tailBytes)
  );
}
Enter fullscreen mode Exit fullscreen mode

safeSliceToCodepointBoundary is the part that actually matters: it takes the byte offset you want and nudges it backward (for the head) or forward (for the tail) until it isn't sitting in the middle of a multi-byte UTF-8 sequence. That's the whole fix. It's maybe 15 extra lines over the naive version, and every one of them is there because "just slice the string" turns out to have a hidden precondition — the cut point has to respect character boundaries — that the one-liner never checked.

I applied the same function to both the normal-completion path and the aborted-command path (a command that gets killed mid-run still needs its captured output truncated the same way), added four unit tests that specifically construct multi-byte content straddling the truncation boundary, and ran it against a real 40,319-byte case: it truncates to exactly 32,768 bytes, cleanly, no dangling surrogates. Full existing shell-tool suite (93 tests) stayed green, tsc and eslint both clean. PR is #28401.

The lesson isn't "test more"

The instinctive advice here is "write better tests before you ship." That's true but incomplete — the naive version did have a test, it just used a fixture (an ASCII log file) that couldn't expose the bug by construction. The actual lesson is narrower: when a fix operates on strings that represent external, untrusted, arbitrary-content bytes — shell output, file contents, API responses, anything you didn't author — string-level operations that look encoding-agnostic (.slice, .length, .substring) usually aren't, and the failure mode is silent corruption rather than a crash. That combination (silent + only-with-specific-input) is exactly the kind of bug that survives code review, survives a shallow test pass, and ships.

The check I use now, mechanically, before merging any function that slices or truncates untrusted string data: does the test fixture contain at least one character outside the Basic Multilingual Plane positioned right at a truncation boundary? If the fixture is all ASCII, the test isn't testing the actual risk, it's testing that the happy path still works — which the naive one-liner would have passed too.

This one didn't come from an AI suggesting something wrong and me catching it. It came from my own first-instinct draft being wrong, in a way a quick glance and a lazy test couldn't have caught. Same failure mode either way, though — plausible-looking code that passes the test you happened to write is not the same claim as correct code, and the gap between those two only shows up when someone deliberately goes looking for it.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

The dangerous part of output caps is that they look like a one-line safety fix. In agent tools, truncation is a semantic decision: where you cut, what marker you leave, and whether stderr/stdout stay intelligible all affect the model’s next action. Corruption is worse than too much text.