[BUG] Linux: PID namespace/procfs mismatch breaks PDF/.ipynb Read and single-file Grep, and can return another file's content
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
pathis 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.pidis a PID in the caller's PID namespace, often1./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.ipynbRead fail when the corresponding outer-process fd is inaccessible or absent. - Single-file Grep silently returns
No matches foundeven 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 foundfor a file that does contain the pattern. The overall run still exits 0. - Misleading errors: PDF and
.ipynbRead 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 containingHELLO PDF 4242(589 bytes in the measured fixture)decoy.pdf: a valid one-page PDF containingDECOY PDF 9999 - this is NOT test.pdf(612 bytes in the measured fixture)hello.txt:needle_9131 hereon line 2decoy.txt:needle_9131 DECOY_TXT_9999 from decoy.txton line 2
For the .ipynb variant, also create:
test.ipynb: a notebook whose relevant output containsNOTEBOOK_OUT_7777decoy.ipynb: a notebook whose relevant output containsDECOY_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.pdfis 589 bytes and containsHELLO PDF 4242.decoy.pdfis 612 bytes and containsDECOY 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:
- Replace the
DECOY=...line inside the fd-collision block above with the matching decoy file. - Set
PROMPTin 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_