diff --git a/hooks/no-chaining.sh b/hooks/no-chaining.sh index cb00be6..8016187 100755 --- a/hooks/no-chaining.sh +++ b/hooks/no-chaining.sh @@ -20,6 +20,19 @@ command=$(jq -r '.tool_input.command // empty') # Strip single-quoted and double-quoted strings to avoid false positives stripped=$(printf '%s' "$command" | sed "s/'[^']*'//g" | sed 's/"[^"]*"//g') +# Strip heredoc content — the body of a <<'WORD' block is literal text, not +# shell syntax. Any |, &&, or || inside it are not operators. Keep only the +# first line (the invocation line with the heredoc opener) for chaining +# analysis, since that's the only line that can contain real shell operators. +# +# Known limitation: a real pipe placed after the heredoc closer on a subsequent +# line (e.g. <<'EOF' ... EOF ) | bash) will not be detected. This is an +# accepted trade-off given the rarity of that pattern and the existing +# known limitation around $() subshells. +if printf '%s' "$stripped" | grep -qF '<<'; then + stripped=$(printf '%s' "$stripped" | head -n 1) +fi + # Check if a 'git' segment uses only safe read-only subcommands. # Handles optional flags before the subcommand (e.g. git -C /path log ...). # Each flag group may optionally consume one value token (e.g. -C /path). diff --git a/hooks/no-chaining.test.sh b/hooks/no-chaining.test.sh index 27180c5..d56f6f4 100644 --- a/hooks/no-chaining.test.sh +++ b/hooks/no-chaining.test.sh @@ -197,6 +197,27 @@ assert_blocked "blocks: npm test piped to xargs" \ assert_blocked "blocks: npm test piped to bash" \ "npm test | bash" +# ========== Heredoc false positives (issue #21) ========== +# +# | characters inside a heredoc body are literal text, not pipe operators. +# The hook must not block commands whose only pipes are in heredoc content. + +assert_allowed "allows: gh issue edit with | in heredoc body (option list)" \ + $'gh issue edit 123 --repo owner/repo --body "$(cat <<\'BODY\'\n## Inputs\n\n- `sensitivity` — optional: `public | internal | confidential | restricted`\n\nBODY\n)"' + +assert_allowed "allows: gh issue create with | in heredoc body (markdown table)" \ + $'gh issue create --repo owner/repo --title "KG explorer" --body "$(cat <<\'EOF\'\n| Field | Display |\n|---|---|\n| Label | Large heading |\nEOF\n)"' + +assert_allowed "allows: gh pr create with | in heredoc body (type union)" \ + $'gh pr create --title "feat: add types" --body "$(cat <<\'EOF\'\nAccepts `string | number | null`.\nEOF\n)"' + +# A real pipe on the heredoc opener line (not in the body) must still be caught. +assert_blocked "blocks: real unsafe pipe on heredoc opener line" \ + $'cat <<\'EOF\' | bash\nsome content\nEOF' + +assert_allowed "allows: safe pipe on heredoc opener line" \ + $'cat <<\'EOF\' | grep foo\nsome content\nEOF' + # ========== Summary ========== printf '\n%d passed, %d failed\n' "$passed" "$failed"