Claude passes `rg -rn` (parsed as `--replace=n`), silently corrupts its own search output, then misattributes it

Status Open
Reported on v2.1.145
Maintainer reply None cached
Activity 11 comments · opened May 24, 2026
[!IMPORTANT] One-liner: Claude reaches for rg -rn / rg -rln (grep -r = recursive muscle memory). In ripgrep -r is --replace=TEXT, so -rn = --replace n: every match is silently rewritten to n in stdout, exit 0, no error. Claude then reads its own corrupted output and misattributes it to an external cause (assuming some output post-processing / compression layer is rewriting identifiers), burning cycles — instead of recognising the self-inflicted flag bug.

Type of Behavior Issue

Other unexpected behavior — incorrect tool/flag usage compounded by self-misdiagnosis of the resulting output. (Closest dropdown alt: "Claude made incorrect assumptions about my project.")

What You Asked Claude to Do

Normal codebase exploration — e.g. "find where generateTimeslotsForBranch is defined", "check preventStrayRequests in the base TestCase". No replacement/rewrite was ever requested.

What Claude Actually Did

  1. Emitted rg -rn "function generateTimeslotsForBranch" app/… (intending recursive + line numbers, à la grep -rn).
  2. ripgrep parsed -rn as --replace=n and printed every match with the matched span rewritten to the literal n — exit 0, no error.
  3. Claude read the garbage output and blamed an external cause — assuming some output post-processing / compression layer was rewriting identifiers (e.g. reading Http::n() and concluding "the real call is being compressed") rather than suspecting its own flags.
  4. Worked around the phantom output-processing problem (re-ran the search a different way, fell back to Read / built-in grep) instead of fixing the actual flag.
  5. Only identified the real cause after the user prompted a re-think.

Real corrupted output produced this session (2026-05-24)

| Command Claude ran | Source line | What ripgrep printed | What Claude wrongly assumed |
|---|---|---|---|
| rg -rn "preventStrayRequests\|…" | Http::preventStrayRequests(); | Http::n(); | "output is being compressed" |
| rg -rn "function generateTimeslotsForBranch" | public function generateTimeslotsForBranches( | public nes( | "a layer is mangling method names" |
| ↑ same | private function generateTimeslotsForBranchEmployeeForRange( | private nEmployeeForRange( | ↑ |
| rg -rln "Group\(.*External…" | #[Group(TestGroup::External->value)] | #[Group(TestGroup::ln->value)] | "output compressed to ln" |

The n / ln is the replacement text (-rnn, -rlnln) bleeding onto the characters left over after the matched span — it is rg -r rewriting stdout, not an external output layer.

Expected Behavior

  • ripgrep recurses by default; for "recursive + line numbers" the correct form is just rg -n "pat" [path].
  • Claude should never pass -r to rg unless it genuinely means --replace.
  • When output collapses to n / ln / nFile / process.env.n, Claude should recognise the rg -r signature and not attribute it to an external output layer (compression / filtering / a hook).

Files Affected

None — -r/--replace only rewrites stdout, never files ("Neither this flag nor any other ripgrep flag will modify your files."). The damage is corrupted reasoning, wasted turns, and wrong intermediate conclusions.

Permission Mode

Any (read-only Bash rg; no approval friction). Observed in default mode.

Can You Reproduce This?

Yes — deterministic.

Steps to Reproduce

printf 'alpha\nbeta\ngamma\n' > /tmp/rgtest.txt
rg -rn 'beta' /tmp/rgtest.txt   # prints: n   (NOT "2:beta")
echo $?                          # 0  — no error
  • -rn first-in-bundle is the silent case. -nr fails loudly (-n boolean, then -r eats the pattern as replacement → exit 1 / "missing argument").

Claude Model

Opus 4.7 (claude-opus-4-7). (Earlier 2026-05-18 occurrences were on prior models — i.e. not model-version-specific.)

Relevant Conversation

Claude: "The rg output shows Http::n() — looks like the output is being compressed/rewritten." Claude (later): "something is mangling the output." User: "explain why did you use the -rn … is it wrong in your training data?" Claude (after reflection): "the 'mangled' output was self-inflicted — -rn parses as --replace=n. I hit the exact predicted symptom, then blamed an external cause instead of my own flag."

This is the load-bearing model-behavior detail: the flag error is common; the secondary failure (confidently misattributing self-corrupted output to an innocent tool) is what turns a 1-line fix into a multi-turn detour.

Impact

  • Silent wrong data: exit 0 means no guardrail trips; corrupted matches can feed downstream edits/decisions.
  • Misdiagnosis cascade: Claude invents an external cause (output compression/filtering) and "works around" a non-existent problem.
  • Systemic frequency (one user's logs alone — see below): hundreds of real invocations.

Frequency data — ~/.claude/projects/ (one user, 2026-05-24)

| What | Count |
|---|---|
| Naive: every literal rg -rn in all logs | 981 |
| Inside an actual Bash "command" field | 351 |
| Files containing it | 121 |
| rg -rln (command-field) | 8 |
| rg -rl (command-field) | 8 |

[!WARNING] The naive 981 is inflated: a global CLAUDE.md hard-stop rule literally contains the string rg -rn and is echoed into every session's context, so logs are stuffed with rule-text, not invocations. Even the 351 command-field hits include a few meta-matches (commands that search for the string). Top-distinct breakdown shows the real signal: - rg -rn \ (multi-line real invocations) ×253 - dozens more genuine rg -rn '<pat>' <path> forms - only ~2–3 meta-searches → real misuse ≈ the large majority of 351, across 121 files. A systemic, recurring behavior — not a one-off.

Count commands (for reproduction)

# Naive — every occurrence (inflated by CLAUDE.md rule-text in context):
rg -uu -o --no-filename -e 'rg -rn' ~/.claude/projects/ | wc -l

# Tighter — only inside an actual Bash command field:
rg -uu -o --no-filename -e '"command":"[^"]*rg -rn' ~/.claude/projects/ | wc -l

# Files containing it:
rg -uu -l -e 'rg -rn' ~/.claude/projects/ | wc -l

# Top distinct invocations (separate real misuse from meta-search):
rg -uu -o --no-filename -e '"command":"[^"]*rg -rn[^"]*' ~/.claude/projects/ \
  | sed -E 's/.*"command":"//' | sort | uniq -c | sort -rn | head -25
  • -uu = --no-ignore --hidden so nothing in .claude/ is skipped.
  • Point rg at the dir ~/.claude/projects/ (it recurses) — not a ** glob.
  • The query we count is itself a rg -rn match — it finds itself.

Suggested Fix / Mitigation

Product-side (claude-code):

  1. Add an rg-flag lint in the Bash tool path: if argv contains -r/-rn/-rln/-rl… and no explicit --replace intent, warn or rewrite to drop -r (ripgrep is recursive by default).
  2. Train/system-prompt the canonical forms and the tell-tale: output collapsing to n/ln/nFile ⇒ self-inflicted rg -r, not an external tool.
  3. Consider surfacing this in the bundled tool guidance so it isn't reliant on per-user CLAUDE.md rules.

Already tried (per-user): a CLAUDE.md hard-stop rule + memories with canonical forms only —

  • rg -n "pat" [path]
  • rg -ln "pat" [path] (files-with-matches)
  • rg -C 3 "pat" [path]

— yet it still recurred this session → a per-user prompt rule is not a reliable fix; product-side guarding is warranted.

Claude Code Version

2.1.145 (Claude Code)

Platform

Anthropic API (first-party Claude Code CLI).

Environment

  • OS: macOS (Darwin 25.3.0)
  • Shell: zsh
  • ripgrep: 15.1.0 (single cross-platform Rust binary — no GNU/BSD split; behaviour identical everywhere)

Additional Context

  • Maintainer-acknowledged ripgrep footgun, declined by design: ripgrep #24, #2251 (exact failure mode from a human), #3138.
  • The model-behavior angle (misattributing self-corrupted output to an external cause) is the novel, claude-code-specific contribution beyond the generic ripgrep footgun.

View original on GitHub ↗

11 Comments

lslv1243 · 2 months ago

Funny that I first ran into this issue in Cursor, so it is likely something in the model.

<img width="577" height="56" alt="Image" src="https://github.com/user-attachments/assets/7bc250ba-2588-4f88-b9d0-57565db1120a" />

nullbio · 2 months ago

This has been happening for many months...

GaryReckard · 2 months ago

This issue comes up for me many times a day. Even though I have specific instructions in my CLAUDE.md, Claude continuously trips over this, and then apologizes that it didn't follow my instructions. Sometimes multiple times in the same response. It's kinda wild, like it has really strong muscle-memory for the rg tool that custom instructions just can't shake.

tenequm · 2 months ago

Confirming this independently on Opus 4.8 (Claude Code) today, and the model-behavior angle in the report is the key part.

I hit it during a global surf -> glim rename. rg -rn -i surf silently rewrote every match to n (surf->n, surface->nace, surfpool->npool). The agent then did exactly the misattribution described here: it blamed an external output layer (assumed a token-compression hook was mangling text), worked around the phantom problem for several turns (routed through Read/grep, even redirected rg output to a file - which of course captured the --replaced bytes too), and only traced it to -r=--replace after toggling the single flag. So +1: the expensive part is the confident misattribution of self-corrupted output, not the flag slip itself.

Also +1 that prompt-side rules do not hold - a CLAUDE.md/memory rule did not prevent the recurrence.

Deterministic workaround that is holding for us: a PreToolUse Bash hook that strips a grep-style -r from rg short-flag bundles before execution (rg -rn->rg -n, rg -rln->rg -ln), leaving standalone -r <value> and --replace untouched, since ripgrep recurses by default. It returns hookSpecificOutput.updatedInput with the corrected command. That reliably neutralizes the footgun where the prompt rule did not. A built-in Bash-tool lint as suggested above would be the proper product-side fix.

nullbio · 2 months ago

Yeah, I get it come up multiple times a day as well, despite me screaming at it in CLAUDE.md. @GaryReckard

It's quite interesting from a model perspective. Like the generalizing nature of Claude has trained out any ability to discern edge cases like this and persist them in a heavier weighted manner. Says something about how LLMs work and their weaknesses.

@tenequm Could you share your hook please?

tenequm · 2 months ago

@nullbio Here it is. PreToolUse Bash hook: strips a grep-style -r from rg short-flag bundles (rg -rn -> rg -n, rg -rln -> rg -ln) before the command runs, since ripgrep recurses by default. Standalone -r <value> and --replace are left untouched.

Save as ~/.claude/hooks/rg-guard.py:

#!/usr/bin/env python3
"""rg -rn means --replace=n (silently rewrites matches to "n", exit 0).
ripgrep recurses by default, so strip a grep-style -r from the flag bundle.
Standalone -r <value> and --replace are left untouched."""
import sys, json, re

d = json.load(sys.stdin)
if d.get("tool_name") != "Bash":
    sys.exit()
cmd = (d.get("tool_input") or {}).get("command", "")
seg = r"(^|[|;&]\s*)((?:\w+=\S+\s+|command |sudo |env |time )*rg\s[^|;&]*)"
new = re.sub(seg, lambda m: m[1] + re.sub(r"(^|\s)-r([a-zA-Z]+)\b", r"\1-\2", m[2]), cmd)
if new != cmd:
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PreToolUse", "permissionDecision": "allow",
        "permissionDecisionReason": "rg-guard: stripped grep-style -r (rg -r is --replace)",
        "updatedInput": {"command": new}}}))

Then add to ~/.claude/settings.json and restart Claude Code:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [
        { "type": "command", "command": "python3 ~/.claude/hooks/rg-guard.py" }
      ]}
    ]
  }
}
tenequm · 2 months ago

@nullbio update on the hook above - found a bug: the segment regex [|;&] misses rg commands on separate lines in a multi-statement script (newline is a valid shell separator). Fixed version that handles \n, ||, &&, and skips segments where rg appears as an argument rather than the command:

#!/usr/bin/env python3
"""rg -rn means --replace=n. ripgrep recurses by default; drop grep-style -r."""
import sys, json, re

SEG = re.compile(r"(\s*(?:\|\||&&|[|;\n])\s*)")
ENV = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
BUNDLE = re.compile(r"(^|\s)-r([a-zA-Z]+)\b")
WRAP = {"command", "time", "sudo", "nice", "stdbuf", "env"}

def is_rg(s):
    t = s.strip().split(); i = 0
    while i < len(t) and (ENV.match(t[i]) or t[i] in WRAP): i += 1
    return i < len(t) and t[i] == "rg"

def fix(s): return BUNDLE.sub(lambda m: m[1] + "-" + m[2], s) if is_rg(s) else s

try:
    d = json.load(sys.stdin)
    if d.get("tool_name") != "Bash": sys.exit()
    cmd = (d.get("tool_input") or {}).get("command") or ""
    if "rg" not in cmd or "-r" not in cmd: sys.exit()
    new = "".join(fix(p) if not SEG.fullmatch(p) else p for p in SEG.split(cmd))
    if new != cmd:
        print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse",
            "permissionDecision": "allow",
            "permissionDecisionReason": "rg-guard: dropped grep-style -r",
            "updatedInput": {"command": new}}}))
except Exception: pass
nullbio · 2 months ago

Thank you.

adriaanzon · 2 months ago

The grep muscle-memory is bothering me as well, and a CLAUDE.md rule doesn't hold for me either.

Worth flagging is a closely related footgun I'm frequently running into: \|. Ripgrep uses extended regex by default, so | is already alternation, but Claude writes grep-style rg "foo\|bar", where \| matches a literal pipe and silently returns no results. This convinces Claude that the codebase does not contain any results when it searches for multiple keywords at once.

I dropped an rg wrapper in my $PATH, using Fish's argparse helper to reliably parse the -r argument and \|:

#!/usr/bin/env fish
#
# Wrapper around ripgrep that guards two common mistakes:
#
#   - A short -r flag (alone or in a cluster like -rln). In ripgrep -r means
#     --replace, not recursive — ripgrep already searches recursively. Worse, in
#     a cluster like -rln the "ln" becomes the replacement string, silently
#     swallowing the intended flags. We refuse rather than silently
#     substituting. Use --replace if you actually meant it.
#   - A literal \| in the pattern. ripgrep uses extended regex, so \| matches a
#     literal pipe, not alternation. We can't know intent up front, so we run the
#     search and only hint when it finds nothing.
#
# Steering messages go to stderr, unless the caller redirected stderr to
# /dev/null (agents habitually append 2>/dev/null) — then they go to stdout so
# they still reach the caller. In the -r case stdout is safe to use: we refuse
# to run, so there is no search output to contaminate.
#
# Installed first on PATH, shadowing the real rg.

function steer --description 'print message to stderr, or stdout if stderr was discarded'
    if /bin/test /dev/fd/2 -ef /dev/null
        printf '%s\n' $argv
    else
        printf '%s\n' $argv >&2
    end
end

set -l original_args $argv
argparse --move-unknown r 'replace=' -- $argv 2>/dev/null
if set -ql _flag_r
    steer "rg: refusing to run: in ripgrep -r means --replace, not recursive (ripgrep already searches recursively). In a flag cluster like -rln, everything after the r is taken as the replacement string, swallowing the other flags. Retry without the r, or use --replace explicitly if you really meant substitution."
    exit 2
end

/opt/homebrew/bin/rg $original_args
set -l rg_status $status

if test $rg_status -eq 1; and string match -rq -- '\\\\\|' $original_args
    steer "rg found no matches. Note: rg uses extended regex, so \\| matches a literal pipe. If you meant alternation, retry with | instead of \\|."
end

exit $rg_status

Edit: I updated the script to account for Claude discarding stderr when running rg.

navidemad · 2 months ago

Until there's a product-side fix, here's the local workaround I've been running: a Claude Code PreToolUse hook on Bash that blocks the misuse before the command executes.

The detection is deliberately narrow. ripgrep's -r always consumes a value and recursion is already the default, so a glued -r<letter> (-rn, -rln, -Hrn) is always replace-misuse. Non-glued and legit forms pass untouched: rg -r REPL, rg --replace X, rg -n, rg -ln, --type, --no-ignore.

~/.claude/hooks/block-rg-replace.sh:

#!/bin/bash
# Block ripgrep replace-misuse: `rg -rn` / `-rln` / `-Hrn` etc.
# In ripgrep, -r is --replace=TEXT (recursion is the default), so `-rn` silently
# rewrites every match to "n" and exits 0, corrupting the search output, which
# then gets misattributed to an external cause.
# Triggered only on: Bash(*rg *)
set -euo pipefail

COMMAND=$(jq -r '.tool_input.command // ""')

# Match an `rg` command word followed, within the same shell segment, by a
# short-flag cluster where `r` is glued to one or more trailing letters
# (-rn, -rln, -Hrn). For rg, -r always takes a value, so a glued -r<letter> is
# always replace-misuse. Legit forms pass: `rg -r REPL`, `rg --replace X`,
# `rg -n`, `rg -ln`, long flags like `--type`/`--no-ignore`.
if echo "$COMMAND" | grep -qE '(^|[[:space:]]|[;&|(])rg[[:space:]]+([^;&|]*[[:space:]])?-[A-Za-z]*r[A-Za-z]+'; then
  echo "BLOCKED: ripgrep '-r' means --replace, NOT recursive (rg is recursive by default)." >&2
  echo "'-rn'/'-rln' silently rewrites every match to 'n'/'ln' and exits 0, corrupting the output." >&2
  echo "Fix: drop the -r. Use 'rg -n PATTERN' (line numbers) or 'rg -ln PATTERN' (files+line numbers)." >&2
  echo "If earlier rg output looked garbled, THIS was the cause, not an external compression/filtering layer." >&2
  exit 2
fi

exit 0

In settings.json:

{
  "matcher": "Bash",
  "hooks": [
    {
      "type": "command",
      "command": "~/.claude/hooks/block-rg-replace.sh",
      "if": "Bash(*rg *)"
    }
  ]
}

The "if": "Bash(*rg *)" guard keeps the script from running unless rg is actually in the command.

The part that addresses the second failure described in this issue (the misdiagnosis) is the block message. It tells the model the corruption is self-inflicted rather than an output layer, and to re-run without -r. Because the hook fires on the model's own Bash calls, it catches the mistake in the same session: the tool call is denied, the model reads the reason, and retries with rg -n.

I checked it against the cases that matter: it blocks rg -rn/-rln/-Hrn, including inside && chains and xargs rg ..., and it lets rg -n, rg -ln, rg -r REPL, rg --replace X, --type, --no-ignore, and plain rg in pipes through.

Not a substitute for the product-side lint suggested above, but it stops the silent corruption today and turns a confusing exit-0 into a clear, actionable block.

Prevention at the source

I also added one line to my global CLAUDE.md, so the model avoids the flag in the first place and the hook stays a backstop:

With ripgrep (rg), never pass -r. In ripgrep -r is --replace, not recursive (recursion is already the default). So rg -rn/-rln silently rewrites every match and exits 0, corrupting the output. Use rg -n for line numbers or rg -ln for files plus line numbers. If an rg result looks garbled, that is this flag mistake, not an output filtering layer.

One caveat worth flagging: the hook matches the command text, so it also blocks commands that merely mention rg -rn (a commit message, an echo, a doc edit), not only the ones that run it. That's rare in practice, and the workaround is to pass the text via a file (git commit -F, gh ... --body-file). I kept the regex simple rather than trying to tell "run" from "mention" apart.

uberjay · 1 month ago

Wow. Fable 5 just misused ripgrep in this way, resulting in output like:

[...]
CLAUDE.md:n#n nCnLnAnUnDnEn.nmndn
CLAUDE.md:n
CLAUDE.md:nTnhninsn nfninlnen npnrnonvnindnensn ngnunindnannncnen ntnon nCnlnanundnen nCnondnen n(ncnlnanundnen.nanin/ncnondnen)n nwnhnennn nwnonrnkninnngn nwnintnhn ncnondnen ninnn ntnhninsn nrnenpnonsnintnonrnyn.n
CLAUDE.md:n
CLAUDE.md:n#n#n nPnrnonjnencntn nOnvnenrnvninenwn
[...]

Which then triggered model fallback because of "bio". I don't even know why, but i assume the above output looks something like DNA sequencing!?!???? 🤦 🤦 🤦 🤦 🤦 🤦 🤦 🤦 🤦

(and now my session is poisoned. sigh. 🤪 )