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 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 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 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 Unix, the shell executes the leading `:;` lines and `exec`s the hook script
script directly. 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: 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 ## How `run-hook.cmd` Works at a High Level
`run-hook.cmd` is a polyglot script: Windows treats the first block as batch `run-hook.cmd` is a polyglot script: its first lines start with `:;`, which
commands, while Unix shells treat that block as a no-op heredoc and continue cmd.exe skips as labels and Unix shells execute — the shell execs the hook
after it. before ever reaching the batch block that Windows runs.
Do not copy an implementation from this document. Read `hooks/run-hook.cmd` 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` 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) ### How it works on Unix (bash/sh)
1. `: << 'CMDBLOCK'` opens a heredoc on a no-op command. 1. Each leading `:;` line is a no-op label to cmd.exe but real commands to a
2. The entire CMD batch block is consumed by the heredoc and ignored. POSIX shell.
3. After `CMDBLOCK`, bash resolves the script directory and `exec`s the named 2. The shell checks bash exists (silent exit 0 if not), resolves the script
extensionless script directly. 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 ### 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 @echo off
REM Cross-platform polyglot wrapper for hook scripts. 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: POSIX shells execute the ":;" lines above and exec away before
REM On Unix: the shell interprets this as a script (: is a no-op in bash). 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
REM Hook scripts use extensionless filenames (e.g. "session-start" not REM Hook scripts use extensionless filenames (e.g. "session-start" not
REM "session-start.sh") so Claude Code's Windows auto-detection -- which 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 No bash found - exit silently rather than error
REM (plugin still works, just without SessionStart context injection)
exit /b 0 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" \ CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
bash "$WRAPPER_UNDER_TEST" session-start 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)" cursor_home="$(make_home cursor)"
assert_command_output \ assert_command_output \
"Cursor emits top-level additional_context only" \ "Cursor emits top-level additional_context only" \