Fixing Claude Code's PowerShell Problem with Hooks
TIP: Just paste this url into Claude Code and say "implement this for me"
Claude Code writes great PowerShell, but it's not the best at using it as a shell. It's basically bash on top of PowerShell. Every single session, I see the same thing:
- It tries
powershell.exe -Command(Windows PowerShell 5.1, modules don't load) - It fails, switches to
pwsh -Commandwith inline code (bash eats the quoting) - It fails again, finally writes a
.ps1file and runspwsh -File(works instantly)
Three attempts. Most times. Even with explicit instructions in CLAUDE.md telling it to go straight to the script file approach. I documented the correct pattern months ago and it still burns through two failures before doing the right thing.
Why This Happens
Claude Code runs inside Git Bash on Windows. When you pass PowerShell commands inline through bash, everything goes wrong:
$_gets expanded by bash before PowerShell ever sees it. YourWhere-Object { $_.Name }becomesWhere-Object { .Name }. Silent, wrong results.- Backslash paths get mangled.
C:\Users\clbecomesC:Userscl. - Quoting is impossible. PowerShell's quoting rules conflict with bash's. Nested quotes, single vs double, escape characters — it's a guaranteed mess.
- powershell.exe (5.1) can't load modules in Claude Code's execution context.
ConvertTo-SecureString,Invoke-Command, basic stuff just fails with "module could not be loaded."
These are tracked as open issues on GitHub (#5049, #16225) with no official fix as of today in February 2026.
The Fix: A PreToolUse Hook
I LOVE HOOKS! Have I ever written one? No. Have I written any code in the past several months? No. Am I in heaven? Yes.
I often hang out on the reddit AI forums, but don't post (hell no) and a lot of people mentioned hooks. I thought they were talking about Git hooks and not Claude Code hooks.
So Claude Code hooks let you intercept tool calls before they execute. A PreToolUse hook on the Bash tool can inspect the command and actually BLOCK(!) it if it matches a bad pattern. Amazing.
I didn't know this, though. So I asked Claude to go and research hook and probably pasted it a discussion on reddit and then I said how can this apply to me?
So it gave me some hooks and after I understood how they worked, I asked Claude to help me build hooks to prevent how it interacts with PowerShell and the repeated errors I see.
Create ~/.claude/hooks/validate-powershell.ps1:
1# PreToolUse hook: Block inline PowerShell execution from Bash.
2# Exit code 0 = allow, exit code 2 = block (stderr sent to Claude).
3
4$data = [Console]::In.ReadToEnd() | ConvertFrom-Json
5
6if ($data.tool_name -ne 'Bash') { exit 0 }
7
8$command = $data.tool_input.command
9if (-not $command) { exit 0 }
10
11# Block powershell.exe entirely (Windows PowerShell 5.1)
12if ($command -match '\bpowershell(\.exe)?\b') {
13 [Console]::Error.WriteLine(
14 "BLOCKED: Do not use powershell.exe (Windows PowerShell 5.1).`n" +
15 "Write a .ps1 file and run: pwsh -NoProfile -File <script.ps1>"
16 )
17 exit 2
18}
19
20# Block pwsh -Command / pwsh -c with inline code
21if ($command -match '\bpwsh(\.exe)?\s+(-[Cc](ommand)?)\b') {
22 [Console]::Error.WriteLine(
23 "BLOCKED: Do not use pwsh -Command/-c with inline code.`n" +
24 "Bash mangles PowerShell quoting, `$_ variables, and paths.`n" +
25 "Write a .ps1 file and run: pwsh -NoProfile -File <script.ps1>"
26 )
27 exit 2
28}
29
30exit 0
The hook receives JSON on stdin with tool_name and tool_input.command. It checks two patterns:
powershell.exe— blocked entirely, you want PowerShell 7pwsh -Command/pwsh -c— blocked, forces the script file approach
Commands like pwsh -NoProfile -File script.ps1 pass through untouched. So does every non-PowerShell command.
Register the Hook
Add the hooks section to ~/.claude/settings.json:
1{
2 "hooks": {
3 "PreToolUse": [
4 {
5 "matcher": "Bash",
6 "hooks": [
7 {
8 "type": "command",
9 "command": "pwsh -NoProfile -File \"C:\\Users\\YOU\\.claude\\hooks\\validate-powershell.ps1\"",
10 "timeout": 10
11 }
12 ]
13 }
14 ]
15 }
16}
That's it. Next session, Claude Code will get blocked on its first bad attempt and immediately switch to the correct pattern.
Bonus: Fixing the Glob Tool Too
While I was at it, I built a hook to manage the errors made by the built-in Glob tool (file search by pattern), which is unreliable on Windows. It uses ripgrep with --no-ignore, which means it scans .git directories, node_modules, build output, everything. On any non-trivial repo, it times out at 20 seconds or fails silently on permission errors (#22379).
fd is a much better alternative. It respects .gitignore by default and is significantly faster. So I installed fd with winget install sharkdp.fd, and added a second hook.
Create ~/.claude/hooks/redirect-glob.ps1:
1# PreToolUse hook: Redirect Glob tool to fd.
2
3$data = [Console]::In.ReadToEnd() | ConvertFrom-Json
4
5if ($data.tool_name -ne 'Glob') { exit 0 }
6
7$pattern = $data.tool_input.pattern
8$path = $data.tool_input.path
9$searchIn = if ($path) { " `"$path`"" } else { '' }
10
11[Console]::Error.WriteLine(
12 "BLOCKED: Glob tool is unreliable on Windows.`n" +
13 "Use fd via Bash instead. Your pattern: $pattern`n" +
14 " fd --type f --glob '$pattern'$searchIn`n" +
15 " fd --type f --extension ps1$searchIn"
16)
17exit 2
Add both hooks to settings.json:
1{
2 "hooks": {
3 "PreToolUse": [
4 {
5 "matcher": "Bash",
6 "hooks": [
7 {
8 "type": "command",
9 "command": "pwsh -NoProfile -File \"C:\\Users\\YOU\\.claude\\hooks\\validate-powershell.ps1\"",
10 "timeout": 10
11 }
12 ]
13 },
14 {
15 "matcher": "Glob",
16 "hooks": [
17 {
18 "type": "command",
19 "command": "pwsh -NoProfile -File \"C:\\Users\\YOU\\.claude\\hooks\\redirect-glob.ps1\"",
20 "timeout": 10
21 }
22 ]
23 }
24 ]
25 }
26}
Claude also added "Bash(fd:*)" to my permissions allow list so fd commands run without prompts.
The "Sibling Tool Call Errored" Thing
If you've seen this in your logs and wondered what it is, it's actually unrelated to PowerShell. When Claude Code uses multiple tool calls in parallel and one fails, the other calls in the same batch get cancelled and show "Sibling tool call errored." Turns out it's nothing to worry about.
What's Actually Happening Under the Hood
If you're curious (and I'm not) PreToolUse hook runs as a subprocess every time Claude Code is about to execute a Bash command. The flow:
- Claude Code decides to run
pwsh -Command 'Get-Process' - Before executing, it pipes JSON to your hook script via stdin
- Your script checks the command against the blocked patterns
- Exit code 2 → command is blocked, stderr message sent back to Claude
- Claude reads the feedback and writes a
.ps1file instead - Hook runs again on
pwsh -File script.ps1→ exit code 0 → allowed
The 10-second timeout keeps calls fast. pwsh startup is a bit slower than python (~500ms vs ~50ms), but hooks only fire on tool calls so it's not noticeable.
All My Hooks
Once I got the hang of it, I went a little nuts. Here's everything I'm running now.
Global Hooks (all projects)
These live in ~/.claude/settings.json and fire on every project.
PreToolUse (before execution):
| Hook | Type | What it does |
|---|---|---|
| PowerShell validator | command | Blocks powershell.exe and pwsh -Command inline. Forces script files. |
| Glob redirect | command | Blocks the built-in Glob tool. Redirects to fd. |
PostToolUse (after writing code):
| Hook | Type | What it does |
|---|---|---|
| Backtick checker | command | Scans .ps1 files for backtick line continuation. Tells Claude to use splatting. |
| Variable-colon detector | prompt | Catches "$vmName: offline" in .ps1 files. PowerShell treats that as a scope reference. Should be "$($vmName): offline". |
Project-Specific Hooks (my LeLab project)
These live in the project's .claude/settings.json and only fire when I'm working in that repo. This is a Hyper-V lab that uses DSC (Desired State Configuration) for everything, so the hooks enforce infrastructure-as-code discipline.
PreToolUse:
| Hook | Type | What it does |
|---|---|---|
| Block auto-generated files | command | Prevents editing configuration/LeLab.ps1 which is compiled from fragments. |
| Lab access validator | prompt | Validates SSH/remote commands to lab VMs. Always allows lab access (credentials are infrastructure-as-code). |
| Ad hoc install blocker | prompt | Blocks winget install, choco install, etc. on lab VMs. Forces software installation through DSC fragments. |
| Ad hoc config blocker | prompt | Blocks ad hoc system changes (firewall rules, registry, services) on lab VMs. Forces DSC. |
PostToolUse:
| Hook | Type | What it does |
|---|---|---|
| Chocolatey enforcer | prompt | Flags raw URL downloads in DSC fragments. Suggests cChocoPackageInstaller instead. |
That's 9 hooks total across two scopes. The global ones fix Claude Code's Windows problems. The project ones enforce my infrastructure-as-code rules so Claude can't take shortcuts that won't survive a lab rebuild.
Command vs Prompt Hooks
You'll notice two types in the table. Command hooks run a script that parses the JSON and makes a decision. Prompt hooks are wild — they send the tool input to Claude itself and ask it to evaluate whether the action is okay. It's Claude reviewing Claude's work. The prompt hooks are slower (they make an extra API call) but they can evaluate nuanced things that regex can't, like "is this command making an ad hoc configuration change on a lab VM?"
Results
Before hooks: three attempts per PowerShell operation, two failures, wasted tokens and time. Boring and expensive.
After hooks: zero failures. Claude Code gets clear feedback on what to do instead and does it right the first time. The glob redirect is even better — fd is genuinely faster than the built-in Glob tool, even when Glob doesn't time out.
Use hooks early and often.