Windows: Claude Code sets FILE_ATTRIBUTE_READONLY on its own ~/.claude subdirs, breaking Bash tool (EEXIST on session-env mkdir)
Summary
On Windows, the running Claude Code process periodically (about every 10 seconds) applies the read-only attribute (FILE_ATTRIBUTE_READONLY) to newly created subdirectories under ~/.claude. This breaks the Bash tool: when a SessionStart hook emits environment variables, the per-session directory ~/.claude/session-env/<session-id>/ is created (to host $CLAUDE_ENV_FILE) and then marked read-only. The Bash tool's own setup then tries to mkdir that same directory non-idempotently and fails with:
EEXIST: file already exists, mkdir 'C:\Users\<user>\.claude\session-env\<session-id>'
The Bash tool is then unusable for the entire session (surfaces as a "Bash tool setup problem / session-env mkdir" error).
Environment
- Claude Code: 2.1.162 (npm global install; also present as the VS Code extension build)
- Model: Opus 4.8 (1M context)
- OS: Windows 11 Home (build 26200)
- Shells: Git Bash / PowerShell
Impact
- Bash tool dead for the whole session. Frequency varies (intermittent to almost always) because it is a race (see below).
- Self-inflicted: Claude Code's own read-only marking breaks Claude Code's own Bash tool.
Root cause (verified on the affected machine)
Two factors combine:
- A hook that emits env vars at SessionStart causes the harness to pre-create
~/.claude/session-env/<session-id>/(to host$CLAUDE_ENV_FILE) before the Bash tool initializes it. (Reproduced here with the Codex Companion plugin, whosesession-lifecycle-hook.mjsappends to$CLAUDE_ENV_FILE.) - The running Claude Code process sets
FILE_ATTRIBUTE_READONLYon that directory within ~10 s of its creation. On a read-only-attributed, already-existing directory, the Bash tool'smkdirthrowsEEXISTinstead of tolerating it.
It is a race: if the Bash tool creates/initializes the directory before the read-only sweep, it works; if the sweep wins, EEXIST.
How we proved Claude Code itself is the source
Controlled experiment:
- New directories created outside
~/.claude(under%USERPROFILE%or%TEMP%) never become read-only. Only new subdirs under~/.claudedo. - Immediately after creation a directory is not read-only; it flips to read-only after ~10–13 s, consistently → a periodic background timer.
- Process-of-elimination via
NtSuspendProcess: suspending the Claude Desktop app, all other Claude Code sessions, and all plugin/MCP/companion node processes (41 processes total) did not stop the read-only marking. Suspending everything except the single Claude Code session's own process left the behavior intact → the marking originates from the Claude Code process itself. - Ruled out: antivirus (only Windows Defender, Controlled Folder Access disabled), third-party backup/sync (none installed), OS default behavior, and user configuration (no such instruction exists).
Whether the responsible code is in Claude Code core or an in-process plugin could not be separated by process suspension (plugins run in-process). Enabled plugins on this machine: superpowers, codex (openai-codex), vercel, context7, github, feature-dev, playwright, claude-md-management, skill-creator, security-guidance, claude-code-setup, playground, code-simplifier.
Reproduction
- Windows, Claude Code 2.1.162.
- Have at least one SessionStart hook that emits env vars (e.g. install the Codex Companion plugin, which appends to
$CLAUDE_ENV_FILE). - Start a session and create a directory under
~/.claude; observe it gains the read-only attribute within ~10 s. - The Bash tool fails on first use with the
EEXISTerror shown above.
Suggested fixes
- Make the Bash tool's session-env directory creation idempotent — treat an existing directory as success (e.g.
fs.mkdirSync(dir, { recursive: true })and do not throwEEXISTwhen the path is already a directory). This alone fixes the crash. - Find and remove whatever sets
FILE_ATTRIBUTE_READONLYon~/.claudesubdirectories — Claude Code should never read-only-mark its own working directories.
Workaround (confirmed working)
A PreToolUse hook (matcher Bash) that strips the read-only attribute from ~/.claude/session-env before every Bash call reliably prevents the EEXIST:
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python -c \"import os,subprocess; b=os.path.join(os.path.expanduser('~'),'.claude','session-env'); subprocess.run(['attrib','-R',os.path.join(b,'*'),'/S','/D'],capture_output=True) if os.path.isdir(b) else None\"",
"timeout": 10
}
]
}This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗