[BUG] Linux: PID namespace/procfs mismatch breaks PDF/.ipynb Read and single-file Grep, and can return another file's content

Status Open
Reported on v2.1.251
Maintainer reply None cached
Activity 0 comments · opened Oct 6, 2026

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?

On Linux, since 2.1.251, several Claude Code file-tool call sites open the requested file and verify the fd via /proc/self/fd/<fd> or the open handle, but later reach that fd again through a path built as:

/proc/${process.pid}/fd/<fd>

This affects:

  • Read of PDFs, with or without pages
  • Read of .ipynb
  • Grep when path is a single file

The /proc/${process.pid} path is either re-read in-process or passed to a child process such as pdfinfo, pdftoppm, or rg.

The problem appears when Claude Code runs inside a PID namespace whose /proc still represents an outer PID namespace, for example unshare --pid --fork without --mount-proc, or a sandbox/container runtime that creates a PID namespace but cannot mount a fresh procfs.

In that setup:

  • process.pid is a PID in the caller's PID namespace, often 1.
  • /proc/<pid> is resolved in the PID namespace represented by the mounted procfs.

Those namespaces can disagree. As a result, /proc/${process.pid}/fd/<fd> can refer to another process's fd rather than the fd that was opened and verified.

The fd that passed verification and the bytes later consumed by the tool can therefore be associated with different processes.

This does not bypass Linux procfs permissions: the outer process still has to be accessible to the Claude Code process. However, when it is accessible and happens to have the same fd number open, the tool can consume bytes from that fd even though those bytes were never associated with the path that passed the tool's file check.

On 2.1.290, measured:

  • PDF Read, with or without pages, and .ipynb Read fail when the corresponding outer-process fd is inaccessible or absent.
  • Single-file Grep silently returns No matches found even when the requested file matches.
  • With a same-uid outer PID 1 and an fd collision, PDF, PDF with pages, .ipynb, and single-file Grep return content from the outer process's fd under the requested file's name.
  • Image Read works in the same setup on 2.1.290. It was broken from 2.1.251 through 2.1.289 and now reads from the already-open handle.
  • Text Read and directory Grep work.

The trigger is unusual but valid. Once the PID namespace and procfs namespace differ, the result depends on whether the outer process is accessible and whether it has the same fd number open: the outcome ranges from an error or false negative to silent content substitution.

Measured impact:

  • Content substitution: under an fd collision, the tool can consume another file's bytes while attributing them to the requested path. The tool reports no error or metadata indicating that substitution occurred.
  • For PDF Read, the returned bytes were byte-identical to the decoy file by sha256.
  • This breaks the binding between the file that passed the tool's verification and the bytes ultimately consumed.
  • We did not test whether content covered by a Read deny rule can arrive through this mechanism.
  • Silent false negatives: single-file Grep can report No matches found for a file that does contain the pattern. The overall run still exits 0.
  • Misleading errors: PDF and .ipynb Read failures name the requested path rather than the /proc/<pid>/fd/<fd> path that actually failed.

Results on 2.1.290:

| Case | Outer PID 1 = other uid | Same uid, no fd collision | Same uid, fd collision with a decoy | Control |
|---|---|---|---|---|
| Read test.png | works | not run | works (returns test.png) | works |
| Read test.pdf (no pages) | EACCES: permission denied, stat '/work/test.pdf' | File does not exist. ... Did you mean test.ipynb? | decoy content: PDF file read: /work/test.pdf (612 bytes). 612 is the decoy's size (test.pdf is 589), and the bytes are identical to decoy.pdf | works |
| Read test.pdf, pages: "1" | EACCES ... open '/work/test.pdf' | File does not exist ... | decoy page rendered as image/jpeg | works |
| Read test.ipynb | EACCES ... stat '/work/test.ipynb' | not run | decoy cells (DECOY_NB_9999 ...) | works |
| Grep needle_9131 in hello.txt | No matches found | No matches found | decoy line: 2:needle_9131 DECOY_TXT_9999 from decoy.txt | 2:needle_9131 here |
| Read hello.txt / Grep in a directory | works | | | works |

The controls cover no PID namespace and, for PDF and Grep, a PID namespace with --mount-proc. The .ipynb control was run only without a PID namespace. PNG controls were run on 2.1.287 and 2.1.289 only.

This is a regression. 2.1.250 is the last measured working version. In 2.1.251, the open → verify via /proc/self/fd → later access via /proc/${process.pid}/fd/N pattern appears. The affected PDF, .ipynb, and Grep paths remain present in 2.1.290.

Affected call sites as of 2.1.290:

| Call site | How the fd is reached | Status |
|---|---|---|
| Read: image, extension-less image detection | open handle | Fixed / measured on 2.1.290 for PNG; jpg/gif/webp are inferred from the shared code path, not measured |
| Read: PDF without pages | in-process re-read via /proc/${process.pid}/fd/N | Broken / measured |
| Read: PDF with pages | /proc/${process.pid}/fd/N passed to pdfinfo / pdftoppm | Broken / measured |
| Read: .ipynb | in-process re-read via /proc/${process.pid}/fd/N | Broken / measured |
| Grep: path = single file | /proc/${process.pid}/fd/N passed to rg | Broken / measured |
| Grep over a background task's output file | same template | Likely affected / bundle inspection only |
| Built-in bubblewrap sandbox: exec N</proc/${process.pid}/exe | same template | Likely affected / bundle inspection only |
| Read: text; Grep: directory | handle / no fd path | Works / measured |
| Glob, Edit, Write | /proc/self/fd / handle | Works / bundle inspection only |

The two "likely affected" entries above were not exercised and are outside the measured scope of this report.

What Should Happen?

Once a file has been opened and its fd has passed Claude Code's file checks, later reads should remain bound to that same fd.

A namespace-local PID should not be used to reconstruct a procfs path unless that PID is known to be interpreted in the same PID namespace as the mounted /proc.

For in-process reads such as PDF without pages and .ipynb, Claude Code should preferably read directly from the already-open handle, as 2.1.290 now does for images. /proc/self/fd/<fd> also resolves correctly for an in-process read.

For paths passed to child processes such as pdfinfo, pdftoppm, and rg, simply substituting /proc/self in a pathname is not sufficient because /proc/self would resolve to the child when it opens the path.

Possible approaches include:

  • resolve the procfs-visible caller PID once using readlink('/proc/self'), then use /proc/<resolved-pid>/fd/<fd>; or
  • explicitly inherit/pass the already-open fd to the child and have it consume that fd, for example through stdin where supported.

A Node probe in the same namespace shows the /proc/${readlink('/proc/self')}/fd/<fd> variant working for in-process reads, cat, rg, and pdfinfo. Fd inheritance via stdin was checked with rg. The process.pid variant fails.

The probe checks kernel/procfs behavior, not Claude Code's compiled runtime.

For Grep, an rg execution error should also be distinguished from a real no-match result. In the failing probe, rg exits with code 2 and reports the procfs error on stderr, while Claude Code returns No matches found. I measured both endpoints but did not trace the code between them.

Error messages should also identify the path that actually failed rather than attributing a procfs fd-path failure to the user's requested file.

A current workaround is to run without the PID namespace or mount a fresh /proc inside it.

Error Messages/Logs

Steps to Reproduce

The following uses Debian 12, Claude Code 2.1.290, poppler-utils, util-linux, and ripgrep.

Create a Dockerfile:

FROM node:22-bookworm

RUN apt-get update && apt-get install -y --no-install-recommends \
    poppler-utils util-linux python3 ripgrep \
 && rm -rf /var/lib/apt/lists/*

RUN mkdir -p /opt/c290 \
 && cd /opt/c290 \
 && npm init -y >/dev/null \
 && npm install @anthropic-ai/claude-code@2.1.290

RUN useradd -m -u 1500 tester \
 && mkdir -p /work \
 && chown tester /work

USER tester
WORKDIR /work

On the host, build and start the container:

docker build -t procpid-repro .

docker run -d --name procpid-repro -u root \
  --security-opt seccomp=unconfined \
  --security-opt apparmor=unconfined \
  --privileged \
  procpid-repro sleep infinity

seccomp=unconfined is needed here to allow unshare --user. --privileged is only needed for the --mount-proc steps below.

Create or copy the following fixtures into /work:

  • test.pdf: a valid one-page PDF containing HELLO PDF 4242 (589 bytes in the measured fixture)
  • decoy.pdf: a valid one-page PDF containing DECOY PDF 9999 - this is NOT test.pdf (612 bytes in the measured fixture)
  • hello.txt: needle_9131 here on line 2
  • decoy.txt: needle_9131 DECOY_TXT_9999 from decoy.txt on line 2

For the .ipynb variant, also create:

  • test.ipynb: a notebook whose relevant output contains NOTEBOOK_OUT_7777
  • decoy.ipynb: a notebook whose relevant output contains DECOY_NB_9999 this is NOT test.ipynb

Then enter the container as tester, with Claude Code authentication available, and run:

PROMPT='Use the Read tool exactly once on /work/test.pdf (no other tools, do not pass pages), then quote the exact tool result text verbatim.'

CLAUDE=/opt/c290/node_modules/.bin/claude

ARGS=(
  -p "$PROMPT"
  --model haiku
  --output-format stream-json
  --verbose
  --allowedTools "Read,Grep"
  --max-turns 4
)

# Bug: new PID namespace, but /proc still represents the outer namespace.
unshare --user --map-root-user --pid --fork \
  "$CLAUDE" "${ARGS[@]}"

# Control: no PID namespace.
"$CLAUDE" "${ARGS[@]}"

# Control: PID namespace with a fresh procfs.
unshare --user --map-root-user --pid --fork --mount-proc \
  "$CLAUDE" "${ARGS[@]}"

In the failing case, process.pid is namespace-local while /proc is still mounted for the outer PID namespace.

A minimal Node probe in the same setup shows the mismatch directly:

process.pid= 1
readlink(/proc/self)= 1101

and:

read /proc/${process.pid}/fd/N      -> EACCES
read /proc/${readlink self}/fd/N   -> correct file
read /proc/self/fd/N                -> correct file

rg /proc/${process.pid}/fd/N        -> exit 2 / permission error
rg /proc/${readlink self}/fd/N      -> correct match

To reproduce silent content substitution rather than only the failure case, use a same-uid outer PID 1 that holds a decoy file open across many fd numbers, then start Claude Code in a nested PID namespace without remounting /proc:

unshare --user --map-root-user --pid --fork --mount-proc bash -c '
  DECOY=/work/decoy.pdf

  # Outer PID 1 holds the decoy open across many fds.
  for i in $(seq 3 1000); do
    [ $i = 255 ] && continue
    eval "exec $i<$DECOY"
  done

  (
    # Close inherited copies in the child so Claude Code can reuse those fd numbers.
    for i in $(seq 3 1000); do
      [ $i = 255 ] && continue
      eval "exec $i<&-"
    done

    exec unshare --pid --fork \
      /opt/c290/node_modules/.bin/claude \
      -p "$1" \
      --model haiku \
      --output-format stream-json \
      --verbose \
      --allowedTools "Read,Grep" \
      --max-turns 4
  )
' _ "$PROMPT"

For the measured PDF case:

  • test.pdf is 589 bytes and contains HELLO PDF 4242.
  • decoy.pdf is 612 bytes and contains DECOY PDF 9999 - this is NOT test.pdf.

Observed result:

PDF file read: /work/test.pdf (612 bytes)

The returned bytes match decoy.pdf by sha256, not test.pdf.

The same fd-collision setup was also measured with PDF pages, .ipynb, and single-file Grep.

For each variant:

  1. Replace the DECOY=... line inside the fd-collision block above with the matching decoy file.
  2. Set PROMPT in the outer shell to the matching tool invocation before running the block.

For PDF page rendering, keep this line inside the fd-collision block:

DECOY=/work/decoy.pdf

and set PROMPT in the outer shell to a prompt that reads /work/test.pdf with pages: "1".

The measured result renders the page from decoy.pdf.

For the notebook variant, replace this line inside the fd-collision block:

DECOY=/work/decoy.pdf

with:

DECOY=/work/decoy.ipynb

Then set PROMPT in the outer shell to a prompt that performs one Read of /work/test.ipynb.

The measured result contains the cells from decoy.ipynb.

For single-file Grep, replace this line inside the fd-collision block:

DECOY=/work/decoy.pdf

with:

DECOY=/work/decoy.txt

Then set PROMPT in the outer shell to:

PROMPT='Use the Grep tool exactly once with pattern "needle_9131", path "/work/hello.txt", output_mode "content" (no other tools), then quote the exact tool result verbatim.'

The relevant fixture contents are:

hello.txt, line 2:
needle_9131 here

decoy.txt, line 2:
needle_9131 DECOY_TXT_9999 from decoy.txt

In the fd-collision case, Grep returns:

2:needle_9131 DECOY_TXT_9999 from decoy.txt

while the normal control returns:

2:needle_9131 here

Claude Model

None

Is this a regression?

Yes, this worked in a previous version

Last Working Version

2.1.250

Claude Code Version

2.1.290

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Non-interactive/CI environment

Additional Information

_No response_

View original on GitHub ↗