Skip to content

fix(plugins): run ralph-wiggum and output-style .sh hooks through bash with a quoted path - #95698

Open
claude[bot] wants to merge 1 commit into
mainfrom
claude/plugin-sh-hooks-explicit-bash-9efw07
Open

claude[bot] wants to merge 1 commit into
mainfrom
claude/plugin-sh-hooks-explicit-bash-9efw07

Conversation

@claude

@claude claude Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Refs #95673. Also fixes the ralph-wiggum / output-style half of #78490.

What changes

Three bundled plugins registered their hook as a bare, unquoted script path:

plugin event before after
ralph-wiggum Stop ${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh bash "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"
learning-output-style SessionStart ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh bash "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh"
explanatory-output-style SessionStart same same

This is the form security-guidance already uses (bash "${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh" …). Each plugin's version goes 1.0.0 → 1.0.1 in plugin.json and in the marketplace.json entry, because the updater compares the plugin.json version: without the bump, existing installs stay on the cached 1.0.0 copy and never receive the new hooks.json.

Why

Shell-form hook commands are handed to a shell (sh -c on macOS/Linux, Git Bash on Windows) with CLAUDE_PLUGIN_ROOT exported, and the shell expands the variable. Three consequences of the bare form:

  1. Spaces in the plugin root break the hook on every platform. Unquoted, the expansion word-splits, the shell tries to execute the first fragment, and the hook exits 127 with not found. For these three hooks that is silent: the output styles contribute no context and the ralph loop never continues. ([BUG] Unquoted ${CLAUDE_PLUGIN_ROOT} in plugin hooks.json breaks all hooks on macOS (space in path); hookify's PreToolUse hook blocks every tool call #78490; a home directory like C:\Users\Jane Doe or /Users/jane doe is enough.)
  2. The bare form depends on the script's exec bit. A delivery path that drops +x turns the hook into Permission denied (exit 126). bash "<path>" reads the script as data and does not care.
  3. On Windows the bare form leans on the CLI. Current Claude Code prepends bash to a hook command whose first word is a .sh file when it runs hooks through Git Bash (see the 2.1.161 changelog entry for Windows: SessionStart hooks that invoke bash fail (exit 127, bash: command not found) #63828), so on an up‑to‑date CLI with Git Bash detected these hooks already get an interpreter — but still unquoted, so point 1 applies, and hosts on older CLIs get the file-association behaviour Windows: three bundled plugins' hooks never run and spawn a console window — hooks.json invokes .sh by bare path #95673 describes. Naming the interpreter in the plugin removes the dependency either way; the CLI leaves a command that already starts with bash untouched.

What could break, and for whom

  • macOS/Linux/Windows-with-Git-Bash users of these plugins whose plugin root has no space: no behaviour change; the same script runs under the same bash (stop-hook.sh is #!/bin/bash, the session-start handlers are #!/usr/bin/env bash).
  • Users whose plugin root contains a space: the hooks start working after they update to 1.0.1.
  • Windows without Git Bash (hooks default to PowerShell): these bash scripts could not run before and still cannot; the failure stays a non-blocking hook error.
  • Rollback: revert this commit; nothing else reads these versions.

Considered and deliberately not done

  • Exec form ("command": "bash", "args": ["${CLAUDE_PLUGIN_ROOT}/…"]) avoids quoting entirely, but on Windows a bare bash spawned without a shell can resolve to WSL's bash.exe rather than Git Bash, and exec form needs a newer CLI than shell form. Shell form with quotes matches the existing convention.
  • "shell": "bash" on each hook would turn the no-Git-Bash Windows case into an explicit "requires bash" error. Left out to keep parity with security-guidance; happy to add if preferred.
  • hookify's unquoted python3 ${CLAUDE_PLUGIN_ROOT}/… has the same word-splitting problem (and blocks every tool call when it hits). It already has two open PRs, fix: quote ${CLAUDE_PLUGIN_ROOT} in plugin hook commands #79644 and fix(plugins): quote ${CLAUDE_PLUGIN_ROOT} in hook commands, prefix hookify examples #81670, which also quote the three files here but keep the bare-path form; this PR supersedes their hunks for these three files only.
  • /ralph-loop's "${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh" runs through the Bash tool, is already quoted, and is the subject of fix(ralph-wiggum): stop parsing /ralph-loop prompt text as shell code #80495; not touched.
  • ralph-wiggum's stop-hook.sh needs jq, which Git for Windows does not ship, so on Windows the Stop hook now launches correctly but still fails at jq when a loop is active. Separate problem; noted as a follow-up.
  • The reporter's two follow-ups — a CI check that every bundled hooks.json command names an interpreter, and a plugin-docs note — are not in this PR. The hooks reference already says shell-form commands run through Git Bash on Windows; the lint is a reasonable follow-up and would be the regression guard this repo currently lacks for plugins/*/hooks/hooks.json.

Verification

  • All seven edited JSON files parse; claude plugin validate passes on the marketplace root and on each of the three plugins (the only warning is the pre-existing security-guidance entry/plugin.json version mismatch).
  • Reproduced the failure and the fix the way the CLI runs shell-form hooks, with the plugins copied under a directory named plugin root with space:
    • before: CLAUDE_PLUGIN_ROOT=".../plugin root with space/ralph-wiggum" sh -c '${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh' → exit 127, sh: 1: .../plugin: not found (same for both session-start hooks)
    • after: sh -c 'bash "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"' → exit 0; the session-start hooks emit valid hookSpecificOutput JSON (1018 and 3034 chars of additionalContext); with an active .claude/ralph-loop.local.md the Stop hook reads stdin and returns {"decision":"block",…,"🔄 Ralph iteration 2 …"}.
    • after, with chmod -x stop-hook.sh: still exit 0; the quoted-but-bare form from fix: quote ${CLAUDE_PLUGIN_ROOT} in plugin hook commands #79644/fix(plugins): quote ${CLAUDE_PLUGIN_ROOT} in hook commands, prefix hookify examples #81670 gives exit 126 Permission denied in that case.
  • End to end with a real CLI (2.1.280-dev, Linux), loading each modified plugin with --plugin-dir from the spaced path in -p mode: the debug log shows Hook SessionStart (bash "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh") provided additionalContext (1018 chars) and the model echoed the explanatory instructions; the unmodified copy logged Hook SessionStart:startup (SessionStart) error: /bin/sh: 1: .../main: not found and the model answered NONE. With ralph-wiggum and an active loop file, the log shows Hook Stop (bash "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh") returned permissionDecision: deny and the loop advanced to iteration 2.
  • Not checked on a Windows machine (none available here). The Windows claim rests on the CLI behaviour described above plus the reporter's own before/after in Windows: three bundled plugins' hooks never run and spawn a console window — hooks.json invokes .sh by bare path #95673.
  • plugins/plugin-dev/.../validate-hook-schema.sh was tried and fails identically on main and on this branch (jq: Cannot index string with number — it does not understand the plugin wrapper format, [BUG] validate-hook-schema.sh fails on plugin hook manifests and non-tool hooks (Fix included) #90292), so it is not a usable check here.
  • This repo has no test harness for plugins/*/hooks/hooks.json, so there is no automated regression test in the diff; the interpreter-prefix lint above would be it.

Generated by Claude Code

…h with a quoted path

The Stop hook in ralph-wiggum and the SessionStart hooks in
learning-output-style and explanatory-output-style gave a bare, unquoted
"${CLAUDE_PLUGIN_ROOT}/....sh" as the hook command. Shell-form hook
commands are expanded by the shell from the CLAUDE_PLUGIN_ROOT env var,
so a plugin root containing a space word-splits and the hook exits 127
("not found") with no visible effect; the bare form also relies on the
script's exec bit and, on Windows, on the CLI prepending an interpreter.

Invoke the scripts as `bash "${CLAUDE_PLUGIN_ROOT}/....sh"`, the form
security-guidance already uses, and bump the three plugins to 1.0.1 so
existing installs pick the change up on update (the plugin.json version
is what the updater compares).

Refs #95673, #78490.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant