[BUG] Grep content output: text before the first colon of context lines is path-normalized on Windows (single-file searches; '//' becomes '\')
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
The Grep tool with output_mode: "content" and any context flag (-A/-B/-C) silently alters the text of context lines before showing them to the model.
When a context line (the ones prefixed 123-) begins with a run of forward slashes and contains a scheme:// URL later in the same line, the leading slash-run is replaced by a single backslash:
| On disk | Shown to the model |
|---|---|
| /// Strips the <c>https://openalex.org/</c> prefix… | \ Strips the <c>https://openalex.org/</c> prefix… |
| // DefaultTimeout sets SQLite's busy_timeout: … http://… | \ DefaultTimeout sets SQLite's busy_timeout: … |
| /* block comment with http://example.com/path */ | \* block comment with http://example.com/path */ |
The file is never modified — this is purely the rendered tool result. But the model reads it as ground truth, and \ at statement position is not valid C#, so the model sees what looks like a syntax error in a file that is actually clean.
The decisive detail: the same line renders correctly as a match line and incorrectly as a context line. Given OpenAlexClient.cs line 343, whose real bytes are 2f 2f 2f (three ASCII forward slashes):
pattern: "Strips the"→343: /// Strips the <c>https://openalex.org/</c> prefix…✅pattern: "NormalizeId", -C: 4→343- \ Strips the <c>https://openalex.org/</c> prefix…❌
Same file, same line, same session, two different renderings depending only on whether the line was matched or included as context.
Why this is worse than a cosmetic glitch
This is the #62016 failure class (silently corrupted search output that the model then misattributes), but reached through the built-in Grep tool with no user error at all: no rg -r, no Bash, no flags beyond a documented -C.
In my session it caused a concrete false-positive cascade:
Grepcontext output showed\ Strips the…; Claude reported a "malformed doc comment" in my file.- I pushed back: "the comment looks fine, otherwise the solution wouldn't compile."
- Claude had already run
Readon the same lines, which showed the correct///. Two tools disagreed and the contradiction went unnoticed. - Claude "resolved" the conflict by trusting the tool that agreed with my pushback and fabricating an explanation for the other, telling me the backslash "was an artifact of how the grep output rendered," i.e. inventing a mechanism rather than checking bytes.
5 Only a hex dump (2f 2f 2f) established the truth.
So the corruption burns turns in both directions: first a phantom defect gets reported, then the correction itself is wrong. A tool that alters source text an agent reasons about is a silent-wrong-data bug, not a formatting nit, and unlike #62016 there is no flag I or the model could have chosen differently.
What Should Happen?
Context lines should be reproduced byte-for-byte from the file, exactly as match lines already are. /// in the file must render as /// whether the line was matched or pulled in as context.
Error Messages/Logs
None. Exit is clean, no warning, no truncation marker. The corrupted text is presented as ordinary tool output: that's what makes it dangerous.
Steps to Reproduce
1. Create Repro.cs:
namespace Repro;
internal sealed class Sample
{
/// <summary>
/// Strips the <c>https://openalex.org/</c> prefix so IDs compare cleanly against bare seeded IDs.
/// </summary>
private static string NormalizeId(string id) => id;
/// <summary>
/// A long doc comment with no URL in it at all, padded out to roughly the same width as the one above so that xx.
/// </summary>
private static string NoUrl(string id) => id;
// http://example.com
private static string L(string id) => id;
/* block comment with http://example.com/path */
private static string J(string id) => id;
}
2. Call the Grep tool (not Bash) with a context flag:
{ "pattern": "private static string", "path": "Repro.cs", "output_mode": "content", "-n": true, "-B": 2 }
3. Observed output: lines 6, 15 and 18 are corrupted; line 11 is not:
5- /// <summary>
6- \ Strips the <c>https://openalex.org/</c> prefix so IDs compare cleanly against bare seeded IDs.
7- /// </summary>
8: private static string NormalizeId(string id) => id;
--
10- /// <summary>
11- /// A long doc comment with no URL in it at all, padded out to roughly the same width as the one above so that xx.
12- /// </summary>
13: private static string NoUrl(string id) => id;
--
15- \ http://example.com
16: private static string L(string id) => id;
--
18- \* block comment with http://example.com/path */
19: private static string J(string id) => id;
4. Confirm the file is clean. Every one of those lines starts with 2f bytes:
$l = [System.IO.File]::ReadAllLines("Repro.cs")[5]
([System.Text.Encoding]::UTF8.GetBytes($l.Substring(0,8)) | % { $_.ToString('x2') }) -join ' '
# 20 20 20 20 2f 2f 2f 20 <- " /// "
5. Contrast with the same line as a match line (renders correctly):
{ "pattern": "Strips the", "path": "Repro.cs", "output_mode": "content", "-n": true }
Trigger isolation (2×2, controlling for line length)
I varied URL-presence and length independently. All four lines are /// on disk:
| Line | Length | Contains scheme:// | Result |
|---|---|---|---|
| /// Strips the <c>https://openalex.org/</c> prefix… | 128 | yes | ❌ corrupted |
| /// Short with <c>https://openalex.org/</c> url. | 52 | yes | ❌ corrupted |
| /// A long doc comment with no URL in it at all… | 118 | no | ✅ clean |
| /// Short doc comment. | 26 | no | ✅ clean |
Length is not the trigger (118 chars clean vs 52 chars corrupted). The scheme:// is.
Further narrowing:
- Any scheme:
ftp://example.com/pathalso corrupts → not http/https-specific. - Needs
scheme://, not just//: a bare//mid-line, a scheme-relative//example.com/path, and a mid-line///all render clean. The:before//is required. - Leading slash-run required: a line containing
http://…inside a string literal, starting withprivate, renders clean. Only the line's leading/-run is rewritten. - Comment style is irrelevant:
///,//, and/*are all affected —/*becomes\*, so the whole leading run collapses to one\. - Match lines are never affected, including ones containing
http://.
Shape of the bug: something in the context-line path appears to treat the line as a URL/path and normalize its leading separators — plausibly a slash-normalizing or path-joining step applied to context lines only. Consistent with the Windows \ separator, though I can't test other platforms.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.220
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
Windows Terminal
Additional Information
- Reproduced on two unrelated real files in one session (
///→\and//→\), then reduced to the minimal cases above. - Both real occurrences were long lines containing URLs, which is why a length/truncation cause was the first (wrong) suspicion; the 2×2 above rules that out.
- I could not test non-Windows platforms. If the cause is separator normalization, POSIX hosts may be unaffected or may show
/collapsing instead — worth checking. - Related but distinct:
- #62016: same class (self-corrupted search output → misattribution) but caused by
rg -rmisuse viaBash. This one needs no user error and comes from the built-in tool. - #78827:
Grepcontentmode substituting a placeholder, but for over-length matching lines.
- Suggested mitigation, in priority order:
- Emit context lines verbatim; whatever transform runs on them should not touch line content.
- If a normalization step is legitimately needed, scope it to paths, never to matched file content.
- Until fixed, a
Readcross-check is the only reliable confirmation — but note the model has no signal that a cross-check is warranted, which is precisely the hazard.
4 Comments
Independently reproduced on Windows (Claude Code 2.1.227, so still present after 2.1.220) — but on a plain Java comment with no URL in it, which led us to a broader trigger characterization and a mechanism that explains every observation in this report, including the match/context asymmetry. Suggest retitling accordingly (proposal at the end).
Our corrupted line (real file, hit twice in unrelated sessions five days apart, byte-identical mangle both times):
rendered as a context line:
No
scheme://anywhere — the only colon is(JC-400):. So the URL condition doesn't hold. Isolating with synthetic files gives:Corrected trigger
A Grep
contentoutput line is corrupted iff all of:-A/-B/-C).pathis a file, so rg omits the filename prefix). Directory mode is immune — see mechanism.The transformation: in the segment before the first colon, every run of
/becomes a single\(//→\,///→\,/*→\*,a/b→a\b). Everything from the first colon onward is untouched. Two corrections to the OP's narrowing:scheme://is not required — any colon works. The 2×2 matrix above confounds the two variables: every URL-bearing doc comment hashttp:as its first colon with the leading///before it, and every "no URL" control line also had no colon at all.a/b: c/d: e/f→a\b: c/d: e/f(only the slash before the first colon flips). The OP's string-literal control (private static … "http://…") rendered clean not because the slashes weren't leading, but because no slash precedes its first colon (http:).All eight clean/corrupt cases in the OP's isolation table are consistent with this rule.
Mechanism: first-colon path split
The behavior is exactly what you'd get from post-processing that splits each rg output line at its first colon, treats the left segment as a file path, Windows-normalizes it (
/→\, collapse duplicate separators), and reassembles:| Output shape | First colon lands on | Effect |
|---|---|---|
| Single file, match line:
710:content| the line-number separator | "path" segment is digits — no visible change || Single file, context line:
710-content| a colon inside file content | everything left of it is path-normalized → corruption || Directory mode, any line:
C:\dir\file.cs:1:content/C:\dir\file.cs-2-content| the drive-letter colon (C:) | "path" segment is justC— shielded |So match lines aren't handled more carefully — the same normalization apparently runs on every line, but only on single-file context lines does the first colon fall inside user content. The drive-letter shield explains why directory searches (the common case) never show it, and why the bug survived unnoticed; verified empirically — the same test file corrupts when grepped by file path and renders clean when the same query runs with
pathset to its parent folder. It also predicts POSIX is silently near-immune (no drive colon, but normalizing to/is mostly idempotent — though//→/collapsing may be observable there).Not ripgrep
System ripgrep 14.1.1 with identical arguments produces correct bytes for the same file (verified via hexdump of both the file and rg's output). The session transcript
.jsonlcontains the corrupted string inside the recordedtool_result(verified via hexdump: file has2f 2f 20, transcript has5c 5c 20= JSON-escaped\), so the rewrite happens in Claude Code's post-processing between rg's stdout and the tool result.Minimal repro (Windows)
Grep tool call:
{"pattern": "MATCH ANCHOR", "path": "C:\\...\\t.txt", "output_mode": "content", "-n": true, "-A": 3}Observed:
Line 2: leading
//collapsed to\;mid/dle(after the first colon) intact. Line 3: mid-line slash flips, but only before the first colon. Line 4: no colon → untouched. Same query withpath= containing folder → all lines correct.Fix implication
Any first-colon split of rg's human-readable output is ambiguous by construction (drive letters, colons in content, dashes in filenames). Parsing
rg --jsonwould eliminate the guessing entirely; short of that, context lines follow the dash grammar (path-line-content) and single-file output has no path prefix to normalize at all.Suggested retitle:
[BUG] Grep content output: text before the first colon of context lines is path-normalized on Windows (single-file searches; '//' becomes '\')@chkwok I've retitled the issue. Interesting findings you've come with. Hope this can be fixed soon
I put Fable / Opus (when it hit safeguards) to work to verify the findings. Here's what it found:
---
Follow-up: located the actual code in the bundled JS (Windows native install,
claude.exe2.1.227). It confirms the first-colon-split mechanism, and corrects one claim in my previous comment — directory-mode searches are not immune in general.The code
From the Grep tool's
call(), theoutput_mode: "content"branch (symbols are minified; deobfuscated names are mine):The intent is clearly display-relativization: turn
C:\abs\path\File.cs:343:contentintoFile.cs:343:content. The bug is that the "is this a path" assumption holds only for match lines in directory mode. It fails for:path-line-content(dashes), so the first colon found is one inside the file content;<lineno><sep><content>.The rewrite itself is
path.win32.relative, which resolves and normalizes: runs of/collapse to a single\. Hence//→\,///→\,/*→\*,a/b→a\b, and only before the first colon.Correction: directory mode is not immune
I previously attributed directory-mode immunity to the drive-letter colon. Wrong on two counts. The drive-letter skip is explicit in the code (
start = 2), so the scan still finds a content colon; and what actually saved my earlier test was therel.startsWith("..")fallback — the test file sat on a different root than the cwd, sopath.relativeproduced a..\..-prefixed string and the original line was returned untouched.Put the same file under the cwd and directory mode corrupts too. With cwd
C:\proj, test fileC:\proj\sub\t.txtcontaining the four lines below, and Grep{pattern: "MATCH ANCHOR", path: "C:\\proj\\sub", output_mode: "content", "-n": true, "-A": 3}:Line 4 is the tell: with no colon in the content,
colon > 0fails, the line is returned verbatim — and so its path is left absolute while every other line got relativized. So the same defect that corrupts content also makes path rendering inconsistent within a single result block, in the exact cases where it doesn't corrupt.Corrected condition set — a content line is rewritten iff it contains a colon (past a leading drive letter) with at least one
/before it, andpath.relative(cwd, head)doesn't escape upward. That covers context lines in both modes, and is why in-repo searches (the common case) are the exposed ones.Suggested fix
Parse
rg --jsoninstead of the human-readable stream — path, line number, and text arrive as separate fields, so no grammar guessing and the relativization applies topathalone. If the text stream must be kept: relativize only when a filename prefix is actually present (i.e. not single-file mode), split on the correct separator for the line kind (:for match,-for context), and bound the path candidate to the prefix rg emitted rather than "everything before the first colon". Noteoutput_mode: "count"has the analogouslastIndexOf(":")split; it's safe forpath:countoutput but rests on the same assumption.Repro cost is one file and one tool call:
and the fix is testable against those four lines — line 2 (leading
//), line 3 (mid-line/), line 4 (no colon, path must still relativize), plus any match line as control.Opus on WSL/Linux
---
Cross-platform confirmation: this is not Windows-specific. Reproduced on Linux (WSL2, kernel 6.6.87.2-microsoft-standard-WSL2, x86_64) with Claude Code 2.1.231 — so still present four versions past the build I disassembled above.
The symptom differs exactly as the code predicts, because
path.posix.normalizecollapses separator runs rather than converting them. Instead of//→\, runs of forward slashes before the first colon are collapsed to a single/.Same seven-line synthetic fixture, placed under the cwd,
output_mode: "content",-n: true,-A: 6, directory mode (cwd/home/user, fixturegreptest/fixture.txt):On disk, lines 2–4 begin
///,//, and/respectively (verified withxxd:2f 2f 2f,2f 2f,2f). Lines 2 and 3 are corrupted; line 4 is not.What each line establishes:
/survives while//and///both become/. That is separator-run collapse insidepath.normalize, not a general rewrite — and it rules out any "leading slashes are stripped" reading of the Windows evidence.colon > 0branch never runs and the line is returned verbatim — leaving its path absolute while every sibling line is relativized. The predicted rendering inconsistency, visible in one output block, on POSIX as on Windows.path.relativewas/home/user/greptest/fixture.txt-7-plain text with colon— path, line number, dash separator, and eleven words of file content, all treated as a single path. It escaped visible damage only because it happens to contain no multi-slash run.pattern: "colon", no context flag). The first colon follows the line number, soheadis"2"andpath.relative(cwd, "2")is"2"— content never enters the substring. Confirms the split is safe forpath:line:textand unsafe forpath-line-text.Single-file mode reproduces identically. Running the same three searches through
rgdirectly produced byte-correct output, so the corruption is in the tool's post-processing, not ripgrep.Reproduction conditions carry over unchanged: the line must appear as a context line, and the fixture must sit under the cwd — otherwise
path.relativereturns a../..-prefixed string, thestartsWith("..")fallback returns the line untouched, and the bug hides. A fixture in/tmpwhile cwd is elsewhere gives a false negative.One extra prerequisite on this build, worth flagging for anyone re-running it: the Grep tool has to actually be in the session's tool list. It was not there by default in the Linux native build and had to be requested explicitly at launch (
claude --tools ...,Glob,Grep,...) — cf. the 2.1.162 CHANGELOG entry, "explicitly listing Grep/Glob now provides the dedicated search tools on native builds with embedded search (previously these names were silently ignored)", and #52121. Without it the model silently falls back torg/grepthrough Bash, which bypasses the post-processing entirely and produces byte-correct output — i.e. a clean-looking false negative. Availability appears to vary by build/configuration (the Windows session used for the analysis above had Grep by default, launched with no--toolsflag), so this is worth checking first rather than assuming.That may also be part of why the defect has gone unnoticed: on builds where the dedicated tool isn't handed out by default, the corrupting path is exercised far less often — but where it is available, nothing distinguishes its output from ground truth.
Practical impact on Linux is the same class as on Windows, and hits the majority comment syntax:
//and///prefixed lines in C, C++, C#, Java, Rust, Go, and JavaScript are silently altered whenever they appear as context lines, with nothing in the output indicating the text differs from disk.Implication for the fix: whatever lands must not be gated on Windows. The
rg --jsonroute fixes both platforms at once; a separator-aware split would need to be applied unconditionally.On the title — entirely your call, and no urgency, since the tool-availability point above means most sessions never hit this at all. But if you do end up editing it again, the parenthetical has drifted from what we now know: it isn't Windows-only, isn't limited to single-file searches, and on POSIX the result is
/rather than\. Something like "text before the first colon of context lines is path-normalized, corrupting source text (Windows and Linux)" would cover it. The scope caveats matter less than the fact that the corruption is silent wherever the tool is available.