Compare commits

...

3 Commits

Author SHA1 Message Date
Drew Ritter
f2bbe9ff92 docs: describe the :; label-line wrapper mechanism (#571) 2026-08-05 15:16:30 -07:00
Drew Ritter
d80fc1808a fix(hooks): replace run-hook.cmd heredoc with :; label lines (#571)
bash >= 5.1 writes heredocs into a pipe pre-fork; under macOS pipe
pressure the kernel hands out 512-byte pipes and the wrapper's 1.4KB
CMDBLOCK heredoc deadlocks every hook. Label lines parse nowhere:
cmd.exe skips them as labels, POSIX shells exec away before the batch
block. Also: silent fail-open when bash or the script name is missing,
and CDPATH-proof directory resolution.
2026-08-05 15:12:47 -07:00
Drew Ritter
431915accb test(hooks): fence against heredocs in the hook chain (red; #571) 2026-08-05 15:08:33 -07:00
5 changed files with 69 additions and 20 deletions

View File

@@ -745,8 +745,10 @@ single file that's valid as both a Windows batch script and a Unix shell script.
On Windows, `cmd.exe` runs the batch portion, which locates `bash` (Git for
Windows, then `bash` on PATH) and runs the named hook script; if no bash is
found it exits cleanly so the harness still works, just without injection. On
Unix, the leading `:` makes the batch block a no-op and the shell runs the
script directly.
Unix, the shell executes the leading `:;` lines and `exec`s the hook script
before reaching the batch block (cmd.exe skips those lines as labels).
Heredocs are banned throughout hooks/ — see issue #571 and the fence test
`tests/hooks/test-no-heredocs-in-hooks.sh`.
Two rules this enforces, which you must respect:

View File

@@ -65,9 +65,9 @@ The path is quoted because `${CLAUDE_PLUGIN_ROOT}` may contain spaces.
## How `run-hook.cmd` Works at a High Level
`run-hook.cmd` is a polyglot script: Windows treats the first block as batch
commands, while Unix shells treat that block as a no-op heredoc and continue
after it.
`run-hook.cmd` is a polyglot script: its first lines start with `:;`, which
cmd.exe skips as labels and Unix shells execute — the shell execs the hook
before ever reaching the batch block that Windows runs.
Do not copy an implementation from this document. Read `hooks/run-hook.cmd`
directly when changing the dispatcher, and run `tests/hooks/test-session-start.sh`
@@ -89,10 +89,14 @@ afterward.
### How it works on Unix (bash/sh)
1. `: << 'CMDBLOCK'` opens a heredoc on a no-op command.
2. The entire CMD batch block is consumed by the heredoc and ignored.
3. After `CMDBLOCK`, bash resolves the script directory and `exec`s the named
extensionless script directly.
1. Each leading `:;` line is a no-op label to cmd.exe but real commands to a
POSIX shell.
2. The shell checks bash exists (silent exit 0 if not), resolves the script
directory CDPATH-proof, and `exec`s the named extensionless script — it
never reads the batch block at all.
3. Heredocs are banned in this file and all hooks/ executables (issue #571:
bash >= 5.1 pre-fork pipe writes deadlock on macOS under pipe pressure);
`tests/hooks/test-no-heredocs-in-hooks.sh` enforces the ban.
### Key design decisions

View File

@@ -1,8 +1,20 @@
: << 'CMDBLOCK'
:; command -v bash >/dev/null 2>&1 || exit 0
:; [ $# -ge 1 ] || exit 0
:; SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
:; SCRIPT_NAME="$1"; shift; exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"
@echo off
REM Cross-platform polyglot wrapper for hook scripts.
REM On Windows: cmd.exe runs the batch portion, which finds and calls bash.
REM On Unix: the shell interprets this as a script (: is a no-op in bash).
REM On Unix: POSIX shells execute the ":;" lines above and exec away before
REM reaching this batch block (":" is a no-op; cmd.exe treats lines starting
REM with ":" as labels and skips them). If bash or the script name is
REM missing, the wrapper exits 0 silently - hooks are optional context, never
REM a session breaker.
REM On Windows: cmd.exe runs this batch portion, which finds and calls bash.
REM
REM Heredocs are banned in this file and every hooks/ executable: bash 5.1+
REM delivers them via a pre-fork pipe write that deadlocks on macOS under
REM pipe pressure (issue #571). tests/hooks/test-no-heredocs-in-hooks.sh is
REM the fence.
REM
REM Hook scripts use extensionless filenames (e.g. "session-start" not
REM "session-start.sh") so Claude Code's Windows auto-detection -- which
@@ -35,12 +47,4 @@ if %ERRORLEVEL% equ 0 (
)
REM No bash found - exit silently rather than error
REM (plugin still works, just without SessionStart context injection)
exit /b 0
CMDBLOCK
# Unix: run the named script directly
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_NAME="$1"
shift
exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
# Heredocs are banned from the hook delivery chain: bash >= 5.1 delivers
# them through a pre-fork pipe write that deadlocks on macOS under pipe
# pressure (issue #571; the #571 class). This fence fails the moment one
# returns. The operator is spelled out of a variable so this file does not
# trip its own check.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
OP='<''<'
FAILURES=0
for f in "$REPO_ROOT"/hooks/*; do
case "$f" in *.json) continue;; esac
[ -f "$f" ] || continue
if hits="$(grep -nE "$OP" "$f")"; then
echo " [FAIL] heredoc operator in ${f#"$REPO_ROOT"/}:"
printf '%s\n' "$hits" | sed 's/^/ /'
FAILURES=$((FAILURES + 1))
else
echo " [PASS] ${f#"$REPO_ROOT"/} is heredoc-free"
fi
done
if [ "$FAILURES" -gt 0 ]; then
echo "STATUS: FAILED ($FAILURES file(s))"
exit 1
fi
echo "STATUS: PASSED"

View File

@@ -184,6 +184,15 @@ assert_command_output \
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
bash "$WRAPPER_UNDER_TEST" session-start
# With no bash available, the wrapper must fail open: silent, exit 0.
if output="$(env -i PATH=/nonexistent /bin/sh "$WRAPPER_UNDER_TEST" session-start 2>&1)" \
&& [ -z "$output" ]; then
pass "wrapper with no bash on PATH is silent and exits 0"
else
fail "wrapper with no bash on PATH is silent and exits 0"
printf '%s\n' "$output" | head -3 | sed 's/^/ /'
fi
cursor_home="$(make_home cursor)"
assert_command_output \
"Cursor emits top-level additional_context only" \