Read tool PDF support broken on Windows — sandbox rejects pdftoppm from all locations

Status Open
Maintainer reply None cached
Activity 3 comments · opened Jun 3, 2026

Description

The Read tool's built-in PDF support does not work on Windows. The tool requires pdftoppm (from Poppler) to render PDF pages as images, but the sandbox rejects the binary regardless of installation location.

Environment

  • OS: Windows 11 Pro 10.0.26200
  • Claude Code version: Latest (as of 2026-06-03)
  • Poppler version: 24.08.0
  • Shell: PowerShell 7+

Steps to Reproduce

  1. Install Poppler for Windows (e.g., via poppler-24.08.0)
  2. Place pdftoppm.exe in C:\Program Files\poppler\ (standard Program Files location)
  3. Add to system PATH and confirm pdftoppm -v works from both PowerShell and Bash
  4. Attempt to read a PDF with the Read tool:

``
Read({ file_path: "C:\path\to\file.pdf", pages: "1" })
``

Expected Behavior

The Read tool renders the PDF page(s) as images and returns them, as documented: "This tool can read PDF files (.pdf)."

Actual Behavior

pdftoppm failed: Command 'pdftoppm' not found or is in an unsafe location (current directory)

This error persists regardless of where pdftoppm.exe is installed:

  • C:\Program Files\poppler\ — rejected
  • C:\tools\poppler\poppler-24.08.0\Library\bin\ — rejected
  • On system PATH, user PATH, or both — rejected

Proof the Binary Works

Running pdftoppm directly from Bash or PowerShell within the same Claude Code session works perfectly:

pdftoppm -png -r 200 -f 1 -l 2 "input.pdf" "output-prefix"
# Produces PNG files successfully
# Read tool can then read the resulting PNG images without issue

Workaround

Two-step process via Bash:

  1. Convert PDF to PNG: pdftoppm -png -r 200 "file.pdf" "output"
  2. Read the PNG with the Read tool

This works reliably but requires extra Bash permission prompts and is not discoverable.

Root Cause (Suspected)

The Read tool's sandbox appears to have a hardcoded allowlist of trusted directories for external executables. On Linux/macOS, pdftoppm is typically at /usr/bin/pdftoppm which is likely on the allowlist. On Windows, there is no standard location that the sandbox recognizes, so it always rejects the binary — even from C:\Program Files\.

Suggestion

Either:

  1. Add C:\Program Files\ to the sandbox's trusted directory list for Windows
  2. Allow a configuration option for trusted binary paths
  3. Use a built-in PDF rendering approach that doesn't depend on an external binary

View original on GitHub ↗

3 Comments

bcfoltz · 1 month ago

**Still broken on current versions — with a different error message, and a decisive env-independent repro.**

Confirming this persists (de-staling the label), and adding evidence that narrows the root cause.

Environment

  • OS: Windows 11 Pro 10.0.26200
  • Claude Code: v2.1.198 (CLI, launched directly from PowerShell — not the desktop app / VS Code extension, so this is not the macOS-desktop PATH issue in #42248/#42203)
  • Poppler: 26.02.0, at C:\Program Files\poppler-26.02.0\Library\bin, which is at index [0] of the machine PATH

Different error string (possible sibling code path)

This issue reports Command 'pdftoppm' not found or is in an unsafe location (current directory) — i.e. the binary was found but its location was rejected. On the CLI I instead get the detection-returned-null message:

pdftoppm is not installed. Install poppler-utils (e.g. `brew install poppler` or `apt-get install poppler-utils`) to enable PDF page rendering.

Same user-facing outcome (Read on .pdf fails on Windows despite poppler installed and on PATH), but this string suggests the candidate is being discarded before the location/allowlist check — worth confirming whether these are one bug or two.

Decisive repro — rules out PATH / stale-env / caching

The usual suspects (stale process env, cached negative result, wrong shell) are all excluded by this:

  1. In one PowerShell command, confirm the env is good, then spawn a brand-new, authenticated headless CLI that inherits that exact env:

``powershell
where.exe pdftoppm # -> C:\Program Files\poppler-26.02.0\Library\bin\pdftoppm.exe (v26, first hit)
'' | claude -p 'Use the Read tool with pages="1" on C:\...\file.pdf, then reply RENDERED or ERROR:<text>'

--dangerously-skip-permissions --no-session-persistence --max-budget-usd 1.5
```

  1. Result:

``
ERROR: pdftoppm is not installed. Install poppler-utils ...
``

The child process is fresh (no long-lived cached null), authenticated, and provably inherits a PATH where where.exe pdftoppm resolves to v26 first. pdftoppm -v, pdftoppm -png ..., and Node's child_process.spawnSync('pdftoppm', ...) (and 'pdftoppm.exe') all succeed from the same session. So the renderer is rejecting a correctly-installed, on-PATH, .exe-suffixed binary that its own resolution step can see.

Mechanism hypothesis

The Windows where.exe emits CRLF line endings and can return multiple matches (here: the poppler-26 path, then a MiKTeX poppler-24 path). If the resolver splits the output but doesn't strip the trailing \r, each candidate becomes ...pdftoppm.exe\r, which fails an endsWith('.exe' | '.cmd' | '.bat' | '.com') allowlist check → all candidates discarded → null → "not installed". (Hypothesis only — offered as a lead, not a confirmed diagnosis. A path containing spaces (Program Files) and dots (26.02.0) is another candidate for a parsing/filter edge case.)

Workarounds (both confirmed working end-to-end)

  • Visual: pdftoppm -png -r 100 file.pdf out in Bash, then Read out-01.png — renders cleanly.
  • Text PDFs: pdftotext -layout file.pdf - — full text, no rasterization, no vision tokens. Better default when you don't need to see layout.

Happy to run any additional diagnostics (e.g. capture the exact where.exe bytes, or test a specific build) if it helps pin the filter.

mickey-mikey · 1 month ago

Same failure on the CLI, via the other error string:

pdftoppm is not installed. Install poppler-utils (e.g. `brew install poppler` or `apt-get install poppler-utils`) to enable PDF page rendering.

Environment: Windows 11 Pro 10.0.26200, native build. poppler 25.07.0 at C:\poppler (first where.exe hit), MiKTeX 24.04 and a winget copy also on PATH. pdftoppm -v, where.exe pdftoppm, and Node spawnSync("pdftoppm") succeed from the same environment at all times, including while the Read tool is failing.

Version results

Repro load: 8 parallel loops of Get-FileHash on the claude.exe under test.

| Version | Under repro load | Idle |
|---|---|---|
| 2.1.195 | 3/3 pass | pass |
| 2.1.196 | 3/3 fail | pass |
| 2.1.197 | 2/3 fail | pass |
| 2.1.204 | fails (interactive and headless) | pass (18/18) |

Detection code, from the release binaries

2.1.195:

async function mCf(){if(RZn!==void 0)return RZn;let{code:e,stderr:t}=await $n("pdftoppm",["-v"],{timeout:5000,useCwd:!1});return RZn=e===0||t.length>0,RZn}

2.1.196 (same logic in 2.1.197 and 2.1.204):

async function rDf(){if(vNo!==void 0)return vNo;let{code:e,stderr:t}=await Fn("pdftoppm",["-v"],{timeout:5000,useCwd:!1}),n=e===0||e!==127&&t.length>0;if(n)vNo=!0;return n}

Changes in 2.1.196: (1) exit 127 is treated as not installed; the internal resolver returns 127 with error text on stderr when resolution fails, which on 2.1.195 satisfied t.length>0. (2) Only a positive result is cached; a negative is re-probed on each call.

Repro

$bin = "C:\path\to\claude.exe"
$jobs = 1..8 | ForEach-Object { Start-Job { param($f) while ($true) { Get-FileHash $f -Algorithm SHA256 | Out-Null } } -ArgumentList $bin }
'' | & $bin -p 'Use the Read tool with pages="1" on C:\path\to\any.pdf then reply RENDERED or ERROR:<text>' --no-session-persistence
$jobs | Stop-Job; $jobs | Remove-Job -Force

Fails ~2/3 per fresh process on 2.1.196+.

Controls, each with zero failures:

  • the same 8 hash loops on a different 213 MB file
  • four concurrent claude -p from the same binary, no load
  • idle

Measurements during failing windows: where.exe pdftoppm 170-205 ms, pdftoppm -v 40-70 ms, MsMpEng 0 s CPU over a 44 s window containing 3 failures. A Node re-implementation of the resolver logic run in the same inherited environment resolves correctly; public Bun 1.4.0-canary and 1.3.14 resolve correctly.

Because only a pass is cached, the first pages read of each process is the failure point; one success makes that process immune for its lifetime, and a failed process recovers on any retry made while the machine is quiet.

mickey-mikey · 1 month ago

Follow-up: a container reproduction, plus further tests

Reproduction

Four files in one folder. Base image mcr.microsoft.com/windows/server:ltsc2025, 2 vCPU.

Dockerfile

# escape=`
FROM mcr.microsoft.com/windows/server:ltsc2025
RUN setx /M PATH "%PATH%;C:\poppler-26.02.0\Library\bin"
SHELL ["powershell","-Command"]
ADD https://bitcoin.org/bitcoin.pdf C:/
ADD https://github.com/oschwartz10612/poppler-windows/releases/download/v26.02.0-0/Release-26.02.0-0.zip C:/
ADD https://aka.ms/vs/17/release/vc_redist.x64.exe C:/
RUN Expand-Archive C:\Release-26.02.0-0.zip C:\
RUN C:\vc_redist.x64.exe /install /quiet /norestart
RUN irm https://claude.ai/install.ps1 | iex
COPY repro.ps1 C:/

compose.yaml

services:
  repro:
    build: .
    image: claude-pdf-repro-min
    isolation: hyperv
    cpus: 2
    env_file: token.env

token.envCLAUDE_CODE_OAUTH_TOKEN=<output of: claude setup-token>

repro.ps1

$c = Join-Path $env:USERPROFILE '.local\bin\claude.exe'
$prompt = "Use the Read tool on pages 1 to 3 of C:\bitcoin.pdf, then reply with the exact tool result or error text."
Write-Host ("pdftoppm -v : " + (((pdftoppm -v 2>&1) -join ' ') -replace '\s+',' ').Substring(0,40))
Write-Host ("single idle read renders : " + ((($null | & $c --dangerously-skip-permissions --allowedTools Read -p $prompt) -join ' ') -match 'pages extracted'))
foreach ($round in 1..3) {
  $j = 1..10 | ForEach-Object { Start-Job -ScriptBlock { param($c,$p) ($null | & $c --dangerously-skip-permissions --allowedTools Read -p $p 2>&1) -join ' ' } -ArgumentList $c,$prompt }
  $j | Wait-Job -Timeout 300 | Out-Null
  $r = $j | ForEach-Object { Receive-Job $_ }; $j | Remove-Job -Force
  Write-Host ("round ${round} : " + ($r | Select-String 'pdftoppm is not installed').Count + "/10 report 'pdftoppm is not installed'")
}
docker compose build
docker compose run --rm repro powershell -File C:\repro.ps1

In the baseline, pdftoppm -v prints the version and one idle read renders. Then 10 concurrent reads report the false error at a rate that varies run to run (7 to 9 of 10 across three rounds on this image): poppler is present and resolves in the idle checks, while the concurrent reads report it missing.

A round can read 0 on a quiet or fast host. Re-running, or adding the cmd-spawn load from the first item under Further tests, raises the rate.

Here the load is concurrent claude -p reads rather than the hash-loops in the earlier comment; both reproduce it.

Further tests

  • A non-Claude process-spawn load (cmd /c exit in a loop) reproduced the failure against a single isolated Claude read. Maxing out handles, threads, and memory (Sysinternals Testlimit) did not reproduce it, and 8 vCPU instead of 2 stopped it reproducing.
  • Claude resolves pdftoppm by spawning where.exe with a 5-second limit. Raising that limit in the binary restored rendering under the same load.
  • The message comes from the exit-code mapping already shown in the earlier comment (exit 127 counts as "not installed"; 2.1.195 lacked that term). Patching the current binary to tolerate 127 also restored rendering. In 2.1.207 the gate is u$y(), the same code===0 || (code!==127 && stderr.length>0) as the earlier comment's rDf.

The earlier comment measured standalone where.exe pdftoppm at 170-205 ms during host failures, with a Node/public-Bun resolver replica resolving correctly. The container result here points at the internal where.exe call exceeding the 5-second limit.

Where the roughly 5 seconds go inside the where.exe call was not determined, and the run-to-run variance in reproduction rate is unexplained.