[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)
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:
/procis mounted — not true in minimal containers, chroots, or hardened setups that deliberately omit it;/proc/self/fd/Nis 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 -lon the full path shows every component is a directory andtaskssimply does not exist;mkdir -pon the identical path from a shell succeeds. - Not
TMPDIR. PointingTMPDIRelsewhere 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
mkdiris at 44624, with no interveningcloseor re-openof that number. - Not shared
/tmpbetween 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)
- Debian userland in a Bastille jail on FreeBSD 14.4-RELEASE-p7 (linuxulator),
with linprocfs mounted on /proc and fdescfs on /dev/fd.
- Install Claude Code 2.1.263 or 2.1.268 with the native installer.
cdinto any git repository and startclaude.- Ask for any shell command, e.g.
run ls via bash. - Every Bash tool call fails, before the command runs, with:
````
ENOTDIR: not a directory, mkdir '<scratchpad>/<session-uuid>/tasks'
- 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_
3 Comments
Not working yet on version 2.1.275.
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
La claude 2.1.251 is the last version without this bug.