[BUG] Bash tool creates its session directory via `/proc/self/fd/N/` instead of `mkdirat()` — fails wherever procfs is absent or incomplete (regression in 2.1.263)

Status Open
Reported on v2.1.228
Maintainer reply None cached
Activity 3 comments · opened Sep 11, 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?

Is this a regression? Yes · Last working version: 2.1.228 · Failing: 2.1.263, 2.1.268

What's wrong

Every Bash tool call fails immediately, before the command runs:

ENOTDIR: not a directory, mkdir '/tmp/claude-1001/-home-ferz-progetti-lavoro-officina/<session-uuid>/tasks'

The ask

Use mkdirat(fd, name, mode) instead of mkdir("/proc/self/fd/<fd>/<name>").

mkdirat is the POSIX call for exactly this operation. It takes the directory descriptor directly, gives the same protection against path races and symlink swaps that the /proc/self/fd trick is presumably there to provide, and carries no dependency on procfs being mounted, complete, or present at all.

The /proc/self/fd/N/ form is a Linux-specific shortcut that quietly assumes:

  • /proc is mounted — not true in minimal containers, chroots, or hardened setups that deliberately omit it;
  • /proc/self/fd/N is traversable as a directory — true on Linux, not guaranteed by any standard.

The environment where I hit this is an unsupported one (see below), and I am not asking you to support it. I am reporting it because the same line of code fails on setups you do support, for a reason that has nothing to do with my platform, and because the fix is a one-line substitution to a more portable call.

Where I hit it

Debian userland in a Bastille jail on FreeBSD 14.4-RELEASE-p7 (linuxulator). Not a supported platform. I have also filed the compatibility gap with FreeBSD.

$ claude doctor
Running: native (2.1.268)
Commit: 8d19e585f3f1
Platform: linux-x64
Config install method: native

$ uname -a
Linux claude_officina 5.15.0 FreeBSD 14.4-RELEASE-p7 #0 x86_64 GNU/Linux

Ruled out, with the checks that rule them out

  • Not a stale file at the target path. namei -l on the full path shows every component is a directory and tasks simply does not exist; mkdir -p on the identical path from a shell succeeds.
  • Not TMPDIR. Pointing TMPDIR elsewhere changes the path in the error message and not the failure.
  • Not descriptor reuse. fd 21 is opened at trace line 44608 and closed at 44638; the failing mkdir is at 44624, with no intervening close or re-open of that number.
  • Not shared /tmp between jails. /tmp/claude-1001/ contains only this user's own project directories; no other uid or jail writes there.

One more thing

The in-session diagnosis Claude emits — "a file named tasks exists where a directory is expected" — is wrong and self-generated. It sent me looking for a file that does not exist. It may be worth not emitting a guessed cause for this error at all.

What Should Happen?

The Bash tool should create its session directory with mkdirat(fd, name, mode),
passing the directory descriptor directly, instead of routing the path through
/proc/self/fd/<fd>/. Same protection against path races and symlink swaps,
no dependency on procfs.

In Python, the correct form is os.mkdir(..., dir_fd=fd), which compiles down
to mkdirat(2):

import os

# The parent directory is held open, as it is today.
fd = os.open(parent, os.O_PATH | os.O_DIRECTORY)   # O_PATH is fine as a dir_fd
try:
    # Correct: mkdirat(fd, name, mode) — no procfs involved.
    os.mkdir(session_uuid, mode=0o777, dir_fd=fd)

    # What happens today, and what fails:
    # os.mkdir(f"/proc/self/fd/{fd}/{session_uuid}", 0o777)
finally:
    os.close(fd)

The equivalent in C, which is what the runtime actually issues:

int fd = openat(AT_FDCWD, parent, O_PATH | O_DIRECTORY);
mkdirat(fd, session_uuid, 0777);          /* instead of */
/* mkdir("/proc/self/fd/21/<uuid>", 0777) */

Both forms are equally safe against the race the current code is presumably
guarding against; only one of them requires /proc to be mounted and to expose
/proc/self/fd/N as a traversable directory. The *at() family is POSIX and
works in minimal containers, chroots, and hardened setups where /proc is
deliberately absent — as well as on the platform where I hit this.

Expected result: the Bash tool prepares its scratchpad successfully and runs the
command, on any system where the parent directory itself is writable.

Error Messages/Logs

ENOTDIR: not a directory, mkdir '/tmp/claude-1001/-home-ferz-progetti-lavoro-officina/<session-uuid>/tasks'


A syscall trace shows why. The Bash tool opens the parent directory, keeps the descriptor, and then creates the session directory by building a path through `/proc/self/fd/N/`:


linux_openat(AT_FDCWD, <parent>, 0x218000, 0666) = 21      /* O_PATH|O_DIRECTORY|O_LARGEFILE */
linux_readlink("/proc/self/fd/21", "/tmp/claude-1001/-home-ferz-progetti-lavoro-officina", 4096) = 52
linux_mkdir("/proc/self/fd/21/<session-uuid>", 0777)       ERR#-20 'Not a directory'
close(21)                                                  = 0


The sibling `scratchpad` directory, created in the same run with an absolute path, succeeds. Only the fd-relative form fails. 2.1.228 does not use this form, which is why it works.

Steps to Reproduce

Steps to Reproduce

Two parts. A is the failure as I hit it, which needs my platform. B is the
underlying dependency, reproducible on any Linux box in two commands — that is the
part I'd ask you to look at, since it does not involve my platform at all.

A. The failure as observed (requires FreeBSD linuxulator)

  1. Debian userland in a Bastille jail on FreeBSD 14.4-RELEASE-p7 (linuxulator),

with linprocfs mounted on /proc and fdescfs on /dev/fd.

  1. Install Claude Code 2.1.263 or 2.1.268 with the native installer.
  2. cd into any git repository and start claude.
  3. Ask for any shell command, e.g. run ls via bash.
  4. Every Bash tool call fails, before the command runs, with:

``
ENOTDIR: not a directory, mkdir '<scratchpad>/<session-uuid>/tasks'
``

  1. Install 2.1.228 (curl -fsSL https://claude.ai/install.sh | bash -s 2.1.228)

and repeat step 4: it works.

A truss of the failing process shows the call that fails and the one that
succeeds in the same run:

90091: linux_openat(0xffffff9c,0x80af9dc68,0x218000,0x1b6) = 21 (0x15)
90091: linux_readlink("/proc/self/fd/21","/tmp/claude-1001/-home-ferz-progetti-lavoro-officina",4096) = 52 (0x34)
90091: linux_mkdir("/proc/self/fd/21/8c97a813-4edf-4feb-883d-5cebf2299c8e",511) ERR#-20 'Not a directory'
90091: close(21)                                 = 0 (0x0)

(0x218000 is O_PATH|O_DIRECTORY|O_LARGEFILE. The sibling scratchpad
directory, created in the same run with an absolute path, succeeds.)

I cannot give steps that reproduce A on a stock Linux box, and I'd rather say so
than imply otherwise.
The /proc/self/fd/N/ form does work on Linux. What
follows is the part that matters for supported platforms.

B. The dependency itself — any Linux host, 2 commands

This shows that the code path the Bash tool uses today stops working as soon as
/proc is unavailable, on a normal Linux system, with nothing exotic involved.

Save this as /tmp/demo/t.py:

import os, sys, time

fd = os.open("/tmp/demo", os.O_PATH | os.O_DIRECTORY)
name = f"{sys.argv[1] if len(sys.argv) > 1 else 'run'}-{time.time_ns()}"

try:
    # What the Bash tool does today.
    os.mkdir(f"/proc/self/fd/{fd}/{name}-procfs", 0o777)
    print("procfs form: ok")
except OSError as e:
    print("procfs form: FAILED —", e)

try:
    # The portable form: mkdirat(2), no procfs involved.
    os.mkdir(f"{name}-mkdirat", mode=0o777, dir_fd=fd)
    print("mkdirat form: ok")
except OSError as e:
    print("mkdirat form: FAILED —", e)

Then run it once normally, and once with /proc unmounted in a private mount
namespace (sudo is needed for unshare --mount on most distributions):

$ python3 /tmp/demo/t.py with-proc
procfs form: ok
mkdirat form: ok

$ sudo unshare --mount --fork sh -c 'umount -l /proc; python3 /tmp/demo/t.py no-proc'
procfs form: FAILED — [Errno 2] No such file or directory: '/proc/self/fd/3/no-proc-procfs'
mkdirat form: ok

Output observed on Ubuntu (x86-64), CPython 3, unshare from util-linux. The
filenames carry a nanosecond timestamp so repeated runs can't produce a
misleading EEXIST.

The second run is an ordinary, supported Linux system with /proc unmounted —
the same situation as a minimal container, a chroot, or a hardened setup that
deliberately omits procfs. The form Claude Code uses today fails there;
mkdirat does not.

On my platform /proc is mounted and the same form fails for a different
reason (ENOTDIR rather than ENOENT), which is a FreeBSD linuxulator gap I've
filed separately. Either way the fix is the same one line.

Claude Model

Not sure / Multiple models

Is this a regression?

Yes, this worked in a previous version

Last Working Version

2.1.228

Claude Code Version

2.1.268

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Xterm

Additional Information

_No response_

View original on GitHub ↗

3 Comments

ferz · 27 days ago

Not working yet on version 2.1.275.

DominoTree · 20 days ago

We fixed this at the beginning of the month and hopefully should have it MFCed soon

https://github.com/freebsd/freebsd-src/commit/b3f7a438bb233c85290dcdc5241d86c49100b6b7

https://reviews.freebsd.org/D59393

ferz · 16 days ago

La claude 2.1.251 is the last version without this bug.