Last active
April 15, 2026 15:36
-
-
Save technicalpickles/f24ba1cc62764a50b9074a1bc518831d to your computer and use it in GitHub Desktop.
Reproduce lefthook pty.Start/setsid sandbox failure (evilmartians/lefthook#1392)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # lefthook-playground.yml | |
| # | |
| # Exercise every terminal behavior mode in lefthook. | |
| # Run with: LEFTHOOK_CONFIG=.scratch/tty-playground/lefthook-playground.yml lefthook run <hook> | |
| # | |
| # The probe script reports whether stdout is a tty (pty path) or not (direct exec), | |
| # what color env vars are set, and session/process group info. | |
| # ============================================================ | |
| # 1. DEFAULT: no flags | |
| # Expected: pty.Start path, stdout is a tty, no color env vars | |
| # ============================================================ | |
| test-default: | |
| commands: | |
| probe: | |
| run: .scratch/tty-playground/tty-probe.sh | |
| # ============================================================ | |
| # 2. INTERACTIVE: skips pty, connects stdin | |
| # Expected: direct exec, stdout NOT a tty, stdin from /dev/tty | |
| # ============================================================ | |
| test-interactive: | |
| commands: | |
| probe: | |
| run: .scratch/tty-playground/tty-probe.sh | |
| interactive: true | |
| # ============================================================ | |
| # 3. USE_STDIN: skips pty, passes stdin | |
| # Expected: direct exec, stdout NOT a tty | |
| # ============================================================ | |
| test-use-stdin: | |
| commands: | |
| probe: | |
| run: .scratch/tty-playground/tty-probe.sh | |
| use_stdin: true | |
| # ============================================================ | |
| # 4. FOLLOW: hook-level, streams output live | |
| # Expected: still uses pty (follow doesn't affect exec path), | |
| # but output streams instead of buffering | |
| # ============================================================ | |
| test-follow: | |
| follow: true | |
| commands: | |
| probe: | |
| run: .scratch/tty-playground/tty-probe.sh | |
| # ============================================================ | |
| # 5. NO_TTY: global flag, disables spinner and interactive | |
| # Expected: pty path still used (no_tty only gates interactive), | |
| # spinner suppressed | |
| # ============================================================ | |
| test-no-tty: | |
| commands: | |
| probe: | |
| run: .scratch/tty-playground/tty-probe.sh | |
| # ============================================================ | |
| # 6. NO_TTY + INTERACTIVE: the conflict case | |
| # no_tty neutralizes interactive (Interactive && !DisableTTY = false) | |
| # Expected: falls back to pty path even though interactive was requested | |
| # ============================================================ | |
| test-no-tty-interactive: | |
| commands: | |
| probe: | |
| run: .scratch/tty-playground/tty-probe.sh | |
| interactive: true | |
| # ============================================================ | |
| # 7. COLORS ON: forces CLICOLOR_FORCE=true on child | |
| # Expected: pty path, CLICOLOR_FORCE=true in env | |
| # ============================================================ | |
| test-colors-on: | |
| commands: | |
| probe: | |
| run: .scratch/tty-playground/tty-probe.sh | |
| # ============================================================ | |
| # 8. COLORS OFF: forces NO_COLOR=true on child | |
| # Expected: pty path, NO_COLOR=true in env | |
| # ============================================================ | |
| test-colors-off: | |
| commands: | |
| probe: | |
| run: .scratch/tty-playground/tty-probe.sh | |
| # ============================================================ | |
| # 9. COLORS AUTO: default, no color env vars set | |
| # Expected: pty path, no color env vars (child detects tty itself) | |
| # ============================================================ | |
| test-colors-auto: | |
| commands: | |
| probe: | |
| run: .scratch/tty-playground/tty-probe.sh | |
| # ============================================================ | |
| # 10. INTERACTIVE + COLORS ON: the current sandbox workaround | |
| # interactive skips pty, colors on adds CLICOLOR_FORCE | |
| # Expected: direct exec, CLICOLOR_FORCE=true | |
| # ============================================================ | |
| test-interactive-colors-on: | |
| commands: | |
| probe: | |
| run: .scratch/tty-playground/tty-probe.sh | |
| interactive: true | |
| # ============================================================ | |
| # 11. FOLLOW + INTERACTIVE: both set | |
| # Expected: direct exec (interactive wins for exec path), | |
| # output streams live (follow wins for output routing) | |
| # ============================================================ | |
| test-follow-interactive: | |
| follow: true | |
| commands: | |
| probe: | |
| run: .scratch/tty-playground/tty-probe.sh | |
| interactive: true |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # parallel-output.sh - Verify parallel command output capture works without pty | |
| # | |
| # The maintainer's concern: lefthook uses pty to capture output from parallel | |
| # commands and print each command's output as a clean block after it finishes. | |
| # Does skipping the pty break that? | |
| # | |
| # This script runs parallel commands that each produce identifiable multi-line | |
| # output with deliberate sleeps to maximize the chance of interleaving. If | |
| # output capture works, each command's block should be contiguous in the final | |
| # output. If it's broken, you'd see lines from different commands jumbled | |
| # together. | |
| # | |
| # Usage: | |
| # ./parallel-output.sh | |
| set -uo pipefail | |
| TMPDIR_ROOT="${TMPDIR:-/tmp}" | |
| WORKDIR=$(mktemp -d "$TMPDIR_ROOT/lefthook-parallel.XXXXXX") | |
| cleanup() { rm -rf "$WORKDIR"; } | |
| trap cleanup EXIT | |
| git init -q "$WORKDIR" | |
| git -C "$WORKDIR" commit --allow-empty -m "init" -q | |
| # Each script prints a labeled block of lines with small sleeps between them. | |
| # If output is captured per-command (correct), you'll see all AAA lines together, | |
| # then all BBB lines together (or vice versa). If output is interleaved (broken), | |
| # you'd see AAA/BBB mixed. | |
| for label in AAA BBB CCC; do | |
| cat > "$WORKDIR/emit-${label}.sh" << SCRIPT | |
| #!/usr/bin/env bash | |
| for i in 1 2 3 4 5; do | |
| echo "${label} line \$i [\$(date +%H:%M:%S.%N | cut -c1-12)]" | |
| sleep 0.05 | |
| done | |
| echo "${label} done [\$(date +%H:%M:%S.%N | cut -c1-12)]" | |
| SCRIPT | |
| chmod +x "$WORKDIR/emit-${label}.sh" | |
| done | |
| cat > "$WORKDIR/lefthook.yml" << 'EOF' | |
| output: | |
| - summary | |
| - success | |
| - failure | |
| - execution_out | |
| - execution_info | |
| test-hook: | |
| parallel: true | |
| commands: | |
| aaa: | |
| run: ./emit-AAA.sh | |
| bbb: | |
| run: ./emit-BBB.sh | |
| ccc: | |
| run: ./emit-CCC.sh | |
| EOF | |
| echo "lefthook parallel output capture test" | |
| echo "lefthook version: $(lefthook version 2>/dev/null || echo "not found")" | |
| echo "sandbox: $([ -t 1 ] && echo "no (stdout is tty)" || echo "yes (stdout is not tty)")" | |
| echo "" | |
| # Capture the raw output | |
| output=$(cd "$WORKDIR" && lefthook run test-hook --force 2>&1) | |
| echo "$output" | |
| echo "" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo " Analysis: checking for interleaved output" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "" | |
| # Check if each label's lines are contiguous (no other label's lines in between) | |
| interleaved=false | |
| for label in AAA BBB CCC; do | |
| # Get line numbers where this label appears | |
| lines=$(echo "$output" | grep -n "^${label} " | cut -d: -f1) | |
| if [ -z "$lines" ]; then | |
| echo " WARNING: no output found for ${label}" | |
| continue | |
| fi | |
| first=$(echo "$lines" | head -1) | |
| last=$(echo "$lines" | tail -1) | |
| count=$(echo "$lines" | wc -l | tr -d ' ') | |
| span=$((last - first + 1)) | |
| if [ "$span" -eq "$count" ]; then | |
| echo " ${label}: contiguous (lines ${first}-${last}, ${count} lines)" | |
| else | |
| echo " ${label}: INTERLEAVED (lines ${first}-${last}, but only ${count} of ${span} are ${label})" | |
| interleaved=true | |
| fi | |
| done | |
| echo "" | |
| if [ "$interleaved" = true ]; then | |
| echo " FAIL: output from parallel commands is interleaved." | |
| echo " This means output capture isn't working correctly without pty." | |
| exit 1 | |
| else | |
| echo " PASS: all parallel command output is contiguous." | |
| echo " Output capture works correctly on the non-pty path." | |
| fi |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # repro.sh - Reproduce lefthook pty.Start setsid sandbox failure | |
| # | |
| # Purpose: Self-contained reproduction of https://github.com/evilmartians/lefthook/issues/1392 | |
| # Created: 2026-04-14 | |
| # | |
| # pty.Start() calls setsid(), which macOS/Linux sandboxes block (EPERM). | |
| # This breaks all `run:` commands in sandboxed environments like Claude Code and Codex. | |
| # | |
| # Usage: | |
| # ./repro.sh # run all scenarios | |
| # ./repro.sh <number> # run a specific scenario (1-7) | |
| # | |
| # Run inside a sandbox to see failures. Outside a sandbox, everything passes | |
| # but you can still observe the tty/color differences. | |
| set -uo pipefail | |
| # ── Setup ────────────────────────────────────────────────────── | |
| TMPDIR_ROOT="${TMPDIR:-/tmp}" | |
| WORKDIR=$(mktemp -d "$TMPDIR_ROOT/lefthook-pty-repro.XXXXXX") | |
| cleanup() { rm -rf "$WORKDIR"; } | |
| trap cleanup EXIT | |
| # We need a git repo for lefthook to run | |
| git init -q "$WORKDIR" | |
| git -C "$WORKDIR" commit --allow-empty -m "init" -q | |
| # Probe script: reports terminal state from inside a lefthook command | |
| cat > "$WORKDIR/probe.sh" << 'PROBE' | |
| #!/usr/bin/env bash | |
| echo " stdout is a tty: $([ -t 1 ] && echo "yes (pty path)" || echo "no (direct exec)")" | |
| echo " stdin is a tty: $([ -t 0 ] && echo "yes" || echo "no")" | |
| echo " CLICOLOR_FORCE: ${CLICOLOR_FORCE:-(not set)}" | |
| echo " NO_COLOR: ${NO_COLOR:-(not set)}" | |
| PROBE | |
| chmod +x "$WORKDIR/probe.sh" | |
| # ── Helpers ──────────────────────────────────────────────────── | |
| # Track results for summary | |
| RESULTS=() | |
| write_config() { | |
| cat > "$WORKDIR/lefthook.yml" | |
| } | |
| run_scenario() { | |
| local num="$1" | |
| local title="$2" | |
| local expect="$3" | |
| shift 3 | |
| # remaining args are extra flags to lefthook run | |
| echo "" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo " $num. $title" | |
| echo " expect: $expect" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| local stdin_src="/dev/stdin" | |
| # Check for --stdin-from flag (must be first extra arg) | |
| if [ "${1:-}" = "--stdin-from" ]; then | |
| stdin_src="$2" | |
| shift 2 | |
| fi | |
| local output | |
| local exit_code | |
| output=$(cd "$WORKDIR" && lefthook run test-hook --force "$@" < "$stdin_src" 2>&1) || exit_code=$? | |
| exit_code=${exit_code:-0} | |
| if [ $exit_code -ne 0 ]; then | |
| # Extract the meaningful error from lefthook output | |
| local error_line | |
| error_line=$(echo "$output" | grep -iE "operation not permitted|eperm|setsid|fork/exec" | head -1 | sed 's/^[[:space:]]*//') | |
| local msg="${error_line:-pty.Start blocked by sandbox}" | |
| echo " FAILED (exit $exit_code): $msg" | |
| RESULTS+=("FAIL|$num|$title|$msg") | |
| else | |
| echo "$output" | grep "^ " | head -4 | |
| RESULTS+=("PASS|$num|$title|") | |
| fi | |
| } | |
| # ── Scenarios ────────────────────────────────────────────────── | |
| scenario_1() { | |
| write_config << 'EOF' | |
| test-hook: | |
| commands: | |
| probe: | |
| run: ./probe.sh | |
| EOF | |
| run_scenario 1 \ | |
| "Default (no flags)" \ | |
| "FAILS in sandbox (pty.Start calls setsid, blocked by EPERM)" | |
| } | |
| scenario_2() { | |
| write_config << 'EOF' | |
| test-hook: | |
| commands: | |
| probe: | |
| run: ./probe.sh | |
| interactive: true | |
| EOF | |
| run_scenario 2 \ | |
| "interactive: true (skips pty)" \ | |
| "WORKS, but stdout loses tty status, no color env vars" | |
| } | |
| scenario_3() { | |
| write_config << 'EOF' | |
| test-hook: | |
| commands: | |
| probe: | |
| run: ./probe.sh | |
| use_stdin: true | |
| EOF | |
| # use_stdin caches stdin until EOF, so pipe /dev/null to avoid blocking | |
| run_scenario 3 \ | |
| "use_stdin: true (also skips pty)" \ | |
| "WORKS, same color loss as interactive" \ | |
| --stdin-from /dev/null | |
| } | |
| scenario_4() { | |
| write_config << 'EOF' | |
| test-hook: | |
| commands: | |
| probe: | |
| run: ./probe.sh | |
| interactive: true | |
| EOF | |
| run_scenario 4 \ | |
| "interactive: true + --colors on (current workaround)" \ | |
| "WORKS, CLICOLOR_FORCE=true compensates for lost pty" \ | |
| --colors on | |
| } | |
| scenario_5() { | |
| write_config << 'EOF' | |
| test-hook: | |
| commands: | |
| probe: | |
| run: ./probe.sh | |
| interactive: true | |
| EOF | |
| run_scenario 5 \ | |
| "interactive: true + --no-tty (the conflict)" \ | |
| "FAILS: no_tty neutralizes interactive (Interactive && !DisableTTY = false), falls back to pty" \ | |
| --no-tty | |
| } | |
| scenario_6() { | |
| write_config << 'EOF' | |
| test-hook: | |
| follow: true | |
| commands: | |
| probe: | |
| run: ./probe.sh | |
| EOF | |
| run_scenario 6 \ | |
| "follow: true (streams output live)" \ | |
| "FAILS in sandbox: follow doesn't change exec path, still uses pty" | |
| } | |
| scenario_7() { | |
| write_config << 'EOF' | |
| no_tty: true | |
| test-hook: | |
| commands: | |
| probe: | |
| run: ./probe.sh | |
| EOF | |
| run_scenario 7 \ | |
| "no_tty: true in config (CI mode)" \ | |
| "FAILS in sandbox: no_tty only suppresses spinner, still uses pty" | |
| } | |
| # ── Main ─────────────────────────────────────────────────────── | |
| echo "lefthook pty.Start / setsid sandbox reproduction" | |
| echo "lefthook version: $(lefthook version 2>/dev/null || echo "not found")" | |
| echo "running inside sandbox: $([ -t 1 ] && echo "probably not (stdout is a tty)" || echo "probably yes (stdout is not a tty)")" | |
| if [ "${1:-}" != "" ]; then | |
| "scenario_$1" | |
| else | |
| for i in 1 2 3 4 5 6 7; do | |
| "scenario_$i" | |
| done | |
| fi | |
| # ── Summary ──────────────────────────────────────────────────── | |
| echo "" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo " Results" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "" | |
| pass_count=0 | |
| fail_count=0 | |
| for result in "${RESULTS[@]}"; do | |
| IFS='|' read -r status num title error <<< "$result" | |
| if [ "$status" = "FAIL" ]; then | |
| echo " FAIL $num. $title" | |
| echo " $error" | |
| ((fail_count++)) | |
| else | |
| echo " PASS $num. $title" | |
| ((pass_count++)) | |
| fi | |
| done | |
| echo "" | |
| echo " $pass_count passed, $fail_count failed" | |
| if [ $fail_count -gt 0 ]; then | |
| echo "" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo " Analysis" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "" | |
| echo " All failures share the same root cause: pty.Start() calls" | |
| echo " setsid(), which sandboxes block with EPERM." | |
| echo "" | |
| echo " The only workaround is interactive: true (scenario 2), but:" | |
| echo " - It's semantically wrong (means 'needs user input')" | |
| echo " - Children lose tty, so colors break without --colors on" | |
| echo " - no_tty: true neutralizes it (scenario 5), making it fragile" | |
| echo "" | |
| echo " Suggested fix: skip pty.Start when stdout is not a terminal." | |
| echo " The pty exists to make children think they have a tty for colors." | |
| echo " When stdout isn't a tty (pipes, sandbox), the pty adds nothing" | |
| echo " but the setsid call that breaks. Set CLICOLOR_FORCE=true on the" | |
| echo " non-pty path to preserve color support." | |
| fi | |
| echo "" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment