Skip to content

.claude/settings.json hooks fail on Windows: executed via PowerShell (not bash) and $CLAUDE_PROJECT_DIR not set #4001

Description

@uhaq-st

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:
    {
      "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 '<script>' (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"" }
]
}
]
}
}

  1. Run Copilot and trigger any tool call

copilot
At the prompt, ask it to run a shell command, e.g.:
Run: git --version

  1. 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 /.

  1. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions