diff --git a/hooks/bash-pipeline-guard.sh b/hooks/bash-pipeline-guard.sh new file mode 100755 index 0000000..efc7057 --- /dev/null +++ b/hooks/bash-pipeline-guard.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# PreToolUse hook for Bash — flags pipelines whose left side changes state but +# whose exit status is then consumed by `&&`, `||`, or an `if`-condition. +# +# bug or subtlety: `git push -q origin main 2>&1 | tail -1 && echo "pushed"` +# reads tail's exit status, not git's. A push that was rejected +# as non-fast-forward still reports "pushed". +# +# Contract: +# - exit 0 for anything it does not understand, and for any parse failure. +# A hook that breaks Bash is far worse than the bug it prevents. +# - exit 2 with a short stderr explanation when it sees the pattern. +# Advisory only — never block. +# +# Reads tool_input.command from stdin JSON (same shape post-edit-lint.sh parses). + +set -uo pipefail + +INPUT="$(cat 2>/dev/null || true)" +[ -n "$INPUT" ] || exit 0 +command -v python3 >/dev/null 2>&1 || exit 0 + +# Inline the matcher as a python script in a temp file. Single-quoted heredoc +# prevents shell expansion of $-characters in the python source. +TMPPY="$(mktemp -t bpg.XXXXXX 2>/dev/null || mktemp -t bpg.XXXXXX)" || exit 0 +[ -n "$TMPPY" ] || exit 0 +trap 'rm -f "$TMPPY"' EXIT INT TERM HUP + +cat >"$TMPPY" <<'PYTHON_EOF' +import json +import re +import sys + +raw = sys.stdin.read() +try: + data = json.loads(raw) +except Exception: + # Parse failure → silent pass + sys.exit(0) + +if not isinstance(data, dict): + sys.exit(0) + +tool_input = data.get("tool_input") +if not isinstance(tool_input, dict): + sys.exit(0) + +cmd = tool_input.get("command", "") +if not isinstance(cmd, str) or not cmd: + sys.exit(0) + + +def strip_quotes(s): + # Strip double-quoted and single-quoted strings so we don't match a verb + # that lives inside a quoted argument or echo payload. + s = re.sub(r'"[^"]*"', "", s) + s = re.sub(r"'[^']*'", "", s) + return s + + +# Author has handled the masked-status problem → no need to flag. +if re.search(r"set\s+-o\s+pipefail", cmd): + sys.exit(0) +if re.search(r"\$\{?PIPESTATUS", cmd): + sys.exit(0) + +# Walk the command once and record single-pipe positions, skipping `||`. +pipes = [] +i = 0 +while i < len(cmd): + c = cmd[i] + if c == "|" and i + 1 < len(cmd) and cmd[i + 1] == "|": + i += 2 + continue + if c == "|": + pipes.append(i) + i += 1 + +if not pipes: + sys.exit(0) + +# Left side = everything before the first pipe (the segment whose status we +# care about). +left = strip_quotes(cmd[: pipes[0]]) + +# State-changing verbs. Matched conservatively — these are the verbs the brief +# enumerates, matched as `verb` (not as a prefix). +state_verbs = [ + (r"\bgit\s+push\b", "git push"), + (r"\bgit\s+commit\b", "git commit"), + (r"\bgit\s+merge\b", "git merge"), + (r"\bgit\s+rebase\b", "git rebase"), + (r"\bkubectl\s+apply\b", "kubectl apply"), + (r"\bkubectl\s+delete\b", "kubectl delete"), + (r"\bkubectl\s+patch\b", "kubectl patch"), + (r"\bdocker\s+push\b", "docker push"), + (r"\bhelm\s+upgrade\b", "helm upgrade"), + (r"\bhelm\s+install\b", "helm install"), +] +verb = None +for pat, name in state_verbs: + if re.search(pat, left): + verb = name + break + +# curl is special: only state-changing when paired with a write method or a +# data payload. Read-only `curl ... | jq` must not be flagged. +if not verb and re.search(r"\bcurl\b", left): + if re.search(r"-X\s*(POST|PUT|DELETE)", left, re.IGNORECASE) or re.search( + r"--data", left + ): + verb = "curl" + +if not verb: + sys.exit(0) + +# Is the pipeline's status consumed by `&&`, `||`, or an `if`-test whose +# `then` clause follows the pipeline? +rest = cmd[pipes[-1] + 1 :] +if re.search(r"&&|\|\|", rest): + print(verb) + sys.exit(0) +if re.search(r"\bthen\b", rest): + print(verb) + sys.exit(0) + +sys.exit(0) +PYTHON_EOF + +# Pipe the raw hook input through the python matcher; capture stdout (the verb +# name to surface in the advisory). Any non-zero exit from python (e.g. a real +# crash) is treated as a silent pass — same policy as a parse failure. +RESULT="$(printf '%s' "$INPUT" | python3 "$TMPPY" 2>/dev/null)" || exit 0 +[ -n "$RESULT" ] || exit 0 + +{ + printf 'Pipeline masks %s exit status: &&/|| reads the LAST command in the pipe, not the first. Use ${PIPESTATUS[0]} or drop the pipe.\n' \ + "$RESULT" +} >&2 +exit 2 \ No newline at end of file diff --git a/hooks/tests/test-bash-pipeline-guard.sh b/hooks/tests/test-bash-pipeline-guard.sh new file mode 100755 index 0000000..a31b540 --- /dev/null +++ b/hooks/tests/test-bash-pipeline-guard.sh @@ -0,0 +1,338 @@ +#!/usr/bin/env bash +# Tests for hooks/bash-pipeline-guard.sh +# +# Convention: bash, `set -uo pipefail`, one PASS:/FAIL: per assertion, +# FAIL=1 aggregate, ALL_OK on full pass. +# +# Each test invokes the hook as a subprocess with real JSON on stdin — never +# source the hook, so we test its actual stdin/stdout/stderr contract. + +set -uo pipefail + +PASS_COUNT=0 +FAIL_COUNT=0 +FAIL=0 + +HOOK="$(cd "$(dirname "$0")/.." && pwd)/bash-pipeline-guard.sh" +[ -x "$HOOK" ] || { + echo "FATAL: hook not found or not executable: $HOOK" >&2 + exit 2 +} +command -v python3 >/dev/null 2>&1 || { + echo "FATAL: python3 required to build test JSON" >&2 + exit 2 +} + +pass() { + printf 'PASS: %s\n' "$1" + PASS_COUNT=$((PASS_COUNT + 1)) +} +fail() { + printf 'FAIL: %s\n' "$1" + FAIL_COUNT=$((FAIL_COUNT + 1)) + FAIL=1 +} + +# Build the JSON envelope the hook expects. +json_for_cmd() { + python3 -c ' +import json, sys +print(json.dumps({"tool_input": {"command": sys.argv[1]}})) +' "$1" +} + +# Run the hook with the given stdin payload. Captures stderr to a file (so the +# test name stays readable on stdout) and stdout to nothing. +run_hook_with_stdin() { + local payload="$1" + local stderr_file + stderr_file="$(mktemp)" + printf '%s' "$payload" | "$HOOK" 2>"$stderr_file" >/dev/null + HOOK_RC=$? + HOOK_STDERR="$(cat "$stderr_file")" + rm -f "$stderr_file" +} + +# assert_flag