Skip to content

Instantly share code, notes, and snippets.

@technicalpickles
Last active April 15, 2026 15:36
Show Gist options
  • Select an option

  • Save technicalpickles/f24ba1cc62764a50b9074a1bc518831d to your computer and use it in GitHub Desktop.

Select an option

Save technicalpickles/f24ba1cc62764a50b9074a1bc518831d to your computer and use it in GitHub Desktop.
Reproduce lefthook pty.Start/setsid sandbox failure (evilmartians/lefthook#1392)
# 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
#!/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
#!/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