Skip to content

Commit 789c9bb

Browse files
fix(plugin): quote CLAUDE_PLUGIN_ROOT in hooks for paths with spaces (#442)
All 5 hook command entries in hooks.json referenced \${CLAUDE_PLUGIN_ROOT} without surrounding double-quotes. On Windows, if the user's profile path contains a space (e.g. C:\Users\John Doe\...), the shell splits the token at the space and the script is not found, breaking every hook. Wrap each expansion in escaped double-quotes so the full path is treated as a single argument regardless of spaces. Add a Go test (TestHooksJSONPluginRootIsQuoted) that parses hooks.json and asserts every CLAUDE_PLUGIN_ROOT reference is properly quoted, and update the existing setup test that asserted the old form. Closes #420
1 parent 36a3bbf commit 789c9bb

4 files changed

Lines changed: 95 additions & 7 deletions

File tree

docs/AGENT-SETUP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ Add to your `.claude/settings.json` (project) or `~/.claude/settings.json` (glob
282282

283283
With bare MCP, add a [Surviving Compaction](#surviving-compaction-recommended) prompt to your `CLAUDE.md` so the agent remembers to use Engram after context resets.
284284

285-
> **Windows note:** The Claude Code plugin hooks use bash scripts. On Windows, Claude Code runs hooks through Git Bash (bundled with [Git for Windows](https://gitforwindows.org/)) or WSL. The `UserPromptSubmit` hook automatically switches to a fork-light safe path under Git Bash/MSYS2: the first-prompt ToolSearch still runs, while later save-reminder checks are skipped so prompt submission does not block. If Git Bash itself is blocked by Defender/EDR, the plugin also ships `scripts/user-prompt-submit.ps1` as a native PowerShell fallback for local override/testing. **Option C (Bare MCP)** remains the no-hook fallback and works natively on Windows without any shell dependency.
285+
> **Windows note:** The Claude Code plugin hooks use bash scripts. On Windows, Claude Code runs hooks through Git Bash (bundled with [Git for Windows](https://gitforwindows.org/)) or WSL. The `UserPromptSubmit` hook automatically switches to a fork-light safe path under Git Bash/MSYS2: the first-prompt ToolSearch still runs, while later save-reminder checks are skipped so prompt submission does not block. If Git Bash itself is blocked by Defender/EDR, the plugin also ships `scripts/user-prompt-submit.ps1` as a native PowerShell fallback for local override/testing. **Option C (Bare MCP)** remains the no-hook fallback and works natively on Windows without any shell dependency. Windows usernames containing spaces (e.g. `C:\Users\John Doe\...`) are supported — all hook commands quote `${CLAUDE_PLUGIN_ROOT}` so the path is passed as a single argument even when it contains spaces.
286286
287287
PowerShell fallback test and local override example:
288288

internal/setup/setup_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2277,7 +2277,7 @@ func TestClaudeCodeUserPromptSubmitHookTimeout(t *testing.T) {
22772277
t.Fatalf("expected one UserPromptSubmit command hook, got %#v", entries)
22782278
}
22792279
hook := entries[0].Hooks[0]
2280-
if hook.Command != "${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-submit.sh" {
2280+
if hook.Command != "\"${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-submit.sh\"" {
22812281
t.Fatalf("unexpected UserPromptSubmit command %q", hook.Command)
22822282
}
22832283
if hook.Timeout != 2 {

plugin/claude-code/hooks/hooks.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"hooks": [
88
{
99
"type": "command",
10-
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh",
10+
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh\"",
1111
"timeout": 10,
1212
"statusMessage": "Loading engram memory..."
1313
}
@@ -18,7 +18,7 @@
1818
"hooks": [
1919
{
2020
"type": "command",
21-
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/post-compaction.sh",
21+
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/post-compaction.sh\"",
2222
"timeout": 10,
2323
"statusMessage": "Recovering engram context after compaction..."
2424
}
@@ -30,7 +30,7 @@
3030
"hooks": [
3131
{
3232
"type": "command",
33-
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-submit.sh",
33+
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-submit.sh\"",
3434
"timeout": 2
3535
}
3636
]
@@ -41,7 +41,7 @@
4141
"hooks": [
4242
{
4343
"type": "command",
44-
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/subagent-stop.sh",
44+
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-stop.sh\"",
4545
"timeout": 10,
4646
"async": true
4747
}
@@ -53,7 +53,7 @@
5353
"hooks": [
5454
{
5555
"type": "command",
56-
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/session-stop.sh",
56+
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/session-stop.sh\"",
5757
"timeout": 5,
5858
"async": true
5959
}

plugin/hooks_quoting_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package plugin_test
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// hooksJSON is the parsed structure of plugin/claude-code/hooks/hooks.json.
11+
type hooksJSON struct {
12+
Hooks map[string][]hookGroup `json:"hooks"`
13+
}
14+
15+
type hookGroup struct {
16+
Matcher string `json:"matcher,omitempty"`
17+
Hooks []hookEntry `json:"hooks"`
18+
}
19+
20+
type hookEntry struct {
21+
Type string `json:"type"`
22+
Command string `json:"command,omitempty"`
23+
Path string `json:"path,omitempty"`
24+
}
25+
26+
// TestHooksJSONPluginRootIsQuoted loads plugin/claude-code/hooks/hooks.json and
27+
// asserts that every command/path field referencing ${CLAUDE_PLUGIN_ROOT} wraps
28+
// the variable expansion in double quotes so that Windows usernames or any path
29+
// component containing spaces does not split the argument at the space.
30+
//
31+
// Correct form (POSIX shell): "${CLAUDE_PLUGIN_ROOT}/scripts/foo.sh"
32+
// Broken form: ${CLAUDE_PLUGIN_ROOT}/scripts/foo.sh
33+
//
34+
// The test fails (red) when the variable is present but unquoted, and passes
35+
// (green) once every occurrence is properly quoted.
36+
func TestHooksJSONPluginRootIsQuoted(t *testing.T) {
37+
root := repoRoot(t)
38+
hooksPath := root + "/plugin/claude-code/hooks/hooks.json"
39+
40+
data, err := os.ReadFile(hooksPath)
41+
if err != nil {
42+
t.Fatalf("cannot read hooks.json: %v", err)
43+
}
44+
45+
var manifest hooksJSON
46+
if err := json.Unmarshal(data, &manifest); err != nil {
47+
t.Fatalf("cannot parse hooks.json: %v", err)
48+
}
49+
50+
const varName = "${CLAUDE_PLUGIN_ROOT}"
51+
// The quoted form that must surround the variable when it appears in a
52+
// command string passed to a POSIX shell.
53+
const quotedForm = `"${CLAUDE_PLUGIN_ROOT}`
54+
55+
checked := 0
56+
for eventName, groups := range manifest.Hooks {
57+
for gi, group := range groups {
58+
for hi, entry := range group.Hooks {
59+
for _, field := range []struct {
60+
name string
61+
value string
62+
}{
63+
{"command", entry.Command},
64+
{"path", entry.Path},
65+
} {
66+
if !strings.Contains(field.value, varName) {
67+
continue
68+
}
69+
checked++
70+
if !strings.Contains(field.value, quotedForm) {
71+
t.Errorf(
72+
"hooks[%q][%d].hooks[%d].%s references %s without surrounding double-quotes:\n got: %s\n want: %s...",
73+
eventName, gi, hi, field.name,
74+
varName,
75+
field.value,
76+
quotedForm,
77+
)
78+
}
79+
}
80+
}
81+
}
82+
}
83+
84+
if checked == 0 {
85+
t.Fatal("no hook entries reference ${CLAUDE_PLUGIN_ROOT} — test is broken or hooks.json changed")
86+
}
87+
t.Logf("checked %d hook command/path field(s) for proper quoting", checked)
88+
}

0 commit comments

Comments
 (0)