.claude/settings.json hooks fail on Windows: executed via PowerShell (not bash) and $CLAUDE_PROJECT_DIR not set
- Lenguaje dominante
- Shell
- Estrellas
- 11.2k
- Forks
- 1.9k
- Merge medio
- 14 h 16 min
- PR fusionados (30 d)
- 6
Descripción
### Describe the bug
### Summary
On Windows, Copilot CLI enforces `.claude/settings.json` hooks (shown as "repo settings")
fail-closed, but executes them in a way incompatible with the Claude Code hook contract those
files are written against. Two distinct problems make every hook — and therefore every tool call —
fail with `Denied by preToolUse hook from "repo settings" (hook errored)`:
1. **Hook `command` strings are executed via PowerShell, not bash.** Claude Code runs the same
commands via bash. A bash-syntax hook command therefore fails with a PowerShell `ParserError`,
and `$VAR` references are expanded by PowerShell (undefined → empty) rather than by the shell
the command was written for.
2. **`$CLAUDE_PROJECT_DIR` is not provided, and hooks run from cwd `/`.** Claude Code documents
`$CLAUDE_PROJECT_DIR` as available to hooks and sets it to the repo root. Copilot does pass the
correct repo path as `cwd` in the hook's stdin JSON, but does not expose it as the documented
environment variable, so path references resolve to a nonexistent `/.claude/hooks/...`.
### Environment
- OS: Windows 11 Pro (10.0.26200)
- Copilot CLI: `@github/copilot` (npm), Node v24.14.0
- Repo contains a standard Claude Code `.claude/settings.json` with PreToolUse / UserPromptSubmit hooks
### Reproduction
1. In a repo with `.claude/settings.json` containing a hook such as:
```json
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [
{ "type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/example.sh\"" }
] }
]
}
}
2. Start Copilot CLI in that repo and run any command (e.g. git --version).
3. The tool call is denied; .copilot/logs/process-*.log shows the hook failure.
Actual behavior
From .copilot/logs/process-*.log:
[ERROR] preToolUse hook from "repo settings" execution failed (fail-closed): Error: Hook command failed with code 1
Stderr: /usr/bin/bash: /.claude/hooks/example.sh: No such file or directory
$CLAUDE_PROJECT_DIR expanded to empty (by PowerShell) → path became /.claude/hooks/....
With a bash-syntax command (no leading bash), the failure is instead:
[ERROR] preToolUse hook from "repo settings" execution failed (fail-closed): Error: Hook command failed with code 1
Stderr: ParserError:
i.e. the command was handed to PowerShell.
Expected behaviour
For compatibility with Claude Code–format hooks, either:
- execute hook command strings via the same shell Claude Code uses (bash/sh) on Windows, and/or
- set $CLAUDE_PROJECT_DIR to the repo root in the hook environment (Copilot already knows this
value — it's in the hook stdin cwd), and run hooks from the project root rather than /.
Notes
Reasonable people can debate whether Copilot should mirror the Claude Code hook contract, but since
Copilot already consumes and enforces these files, the two behaviours above are surprising and
undocumented. Documenting the execution shell and the available env vars for .claude/settings.json
hooks would also resolve this.
Workaround (for others hitting this)
Wrap each hook command as bash -c '' (valid whether the outer shell is bash or PowerShell)
and resolve the repo root from $CLAUDE_PROJECT_DIR if set, else from the stdin cwd:
bash -c 'i=$(cat); d=${CLAUDE_PROJECT_DIR:-}; if [ -z "$d" ]; then r=$(printf %s "$i" | jq -r ".cwd // empty"); d=$(cygpath "$r" 2>/dev/null || printf %s "$r"); fi; printf %s "$i" | bash "$d/.claude/hooks/NAME.sh"'
Also ensure bash resolves to Git Bash rather than the WSL launcher (System32\bash.exe) by putting
C:\Program Files\Git\bin ahead of System32 on PATH.
### Affected version
1.0.67
### Steps to reproduce the behavior
Repro steps (Windows)
Prereqs: Windows, Copilot CLI (@github/copilot) installed, Git for Windows installed (so bash exists). Nothing else.
1. Create a throwaway repo with two hooks
mkdir C:\temp\copilot-hook-repro
cd C:\temp\copilot-hook-repro
git init
mkdir .claude\hooks
Create .claude\hooks\echo-ok.sh (a hook that simply succeeds and proves it ran):
echo "hook ran, args=$0" 1>&2
exit 0
Create .claude\settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/echo-ok.sh\"" }
]
}
]
}
}
2. Run Copilot and trigger any tool call
copilot
At the prompt, ask it to run a shell command, e.g.:
Run: git --version
3. Observe the failure
The tool call is denied (Denied by preToolUse hook from "repo settings" (hook errored)). Inspect the newest log:
Get-ChildItem "$HOME\.copilot\logs\process-*.log" | Sort LastWriteTime -Desc | Select -First 1 | Get-Content -Tail 40
Bug B (empty $CLAUDE_PROJECT_DIR + cwd /) shows as:
Stderr: /usr/bin/bash: /.claude/hooks/echo-ok.sh: No such file or directory
→ $CLAUDE_PROJECT_DIR was expanded to empty (by PowerShell), and the hook ran from cwd /.
4. Isolate Bug A (PowerShell, not bash) — change one line
Edit the command in .claude\settings.json to a bash-syntax command with no leading bash:
"command": "d=${CLAUDE_PROJECT_DIR:-unset}; echo \"dir=$d\" 1>&2; exit 0"
Restart Copilot fresh (see note below), trigger a tool call again, and check the log. You'll now see:
Stderr: ParserError:
→ the command was handed to PowerShell, which can't parse d=${...}. (The identical command runs fine under Claude Code, which executes it via bash.)
- Fresh session required between edits. Copilot caches resolved hooks per session (.copilot\session-state\<id>\events.jsonl) and replays them; it does not re-read settings.json on resume, and new windows may resume an old session. Kill all sessions and decline "resume previous session":
Get-CimInstance Win32_Process | ? { $_.CommandLine -match '@github[\\/]copilot' } | % { Stop-Process -Id $_.ProcessId -Force }
- Rule out the unrelated PATH/WSL variable. If the log shows execvpe(/bin/bash) failed / WSL … Relay instead of the errors above, bash is resolving to the WSL launcher, not Git Bash — that's an environment issue, not the bug being reported. Ensure C:\Program Files\Git\bin precedes System32 on PATH ((Get-Command bash).Source should be Git's) before reproducing, so the log clearly shows the two hook-contract bugs.
### Expected behavior
Since Copilot CLI already consumes and enforces `.claude/settings.json` hooks, it should honor the
Claude Code hook contract those files are written against. Concretely, any one of the following would
fix this; the first is the most complete:
1. **Execute hook `command` strings via the same shell Claude Code uses (bash/sh), not PowerShell.**
A command that runs under Claude Code should run identically under Copilot. Today, bash-syntax
commands fail with a PowerShell `ParserError`, and `$VAR` references are expanded by PowerShell
instead of the intended shell.
2. **Set `$CLAUDE_PROJECT_DIR` in the hook environment**, to the repo root — the same value Copilot
already passes as `cwd` in the hook's stdin. Hook authors rely on this documented variable to
locate their scripts.
3. **Run hooks from the project root**, not cwd `/`, so relative paths like `./.claude/hooks/x.sh`
resolve.
### Additionally
- **Document the hook execution contract on Windows:** which shell runs `command` strings, which
environment variables are available (`$CLAUDE_PROJECT_DIR`, etc.), and the working directory. The
current behavior is undocumented and diverges silently from Claude Code.
- **Reconsider fail-closed on hook infrastructure errors.** A hook that fails to *launch* (missing
file, parser error, shell mismatch) is different from a hook that runs and intentionally returns
non-zero to block an action. Treating a launch/parse failure as a deny blocks every tool call and
makes the CLI unusable, with the only signal buried in `.copilot/logs`. Surfacing the hook's stderr
in the denial message (rather than just "hook errored") would also have made this self-diagnosable.
### Additional context
_No response_
Guía de contribución
Línea de trabajo
Start with the Windows reproduction using .claude/settings.json and inspect the resulting .copilot/logs/process-*.log files. Trace the hook execution entry point to determine the shell, environment, and working directory used for command hooks. Done means the documented Claude Code hook behavior is handled consistently, or the execution contract is explicitly documented and the reproduction no longer fails unexpectedly.
Escrito por el modelo de indexación a partir del texto del issue.
Evaluación
- Stack tecnológico
- bash, powershell, shell
- Área
- cli, developer-experience
- Tipo de issue
- Error
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Estado de actividad
- Tranquilo
- Claridad
- Bastante claro
- Aptitud para principiantes
- 45/100