Created
January 14, 2026 09:49
-
-
Save JoeApo108/2d03dc9dbc3bdcc5317e8b0e788ca6a1 to your computer and use it in GitHub Desktop.
Claude Code hook to auto-approve piped/chained bash commands when all components match allowed patterns (fixes github.com/anthropics/claude-code/issues/13340)
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 | |
| # Auto-approve piped/chained bash commands when all components match allowed patterns | |
| # This fixes the Claude Code bug where piped commands prompt even when individual commands are allowed | |
| set -euo pipefail | |
| # Read input from stdin (Claude passes CLAUDE_TOOL_INPUT as JSON) | |
| INPUT=$(cat) | |
| # Extract the command from the JSON input | |
| COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') | |
| if [[ -z "$COMMAND" ]]; then | |
| # No command found, let Claude Code handle it normally | |
| exit 0 | |
| fi | |
| # Function to load allowed patterns from a settings file | |
| load_patterns_from_file() { | |
| local file="$1" | |
| if [[ -f "$file" ]]; then | |
| # Extract Bash patterns from autoApprovalSettings.patterns and permissions.allow | |
| jq -r ' | |
| ((.autoApprovalSettings.patterns // []) + (.permissions.allow // [])) | |
| | .[] | |
| | select(startswith("Bash(")) | |
| ' "$file" 2>/dev/null || true | |
| fi | |
| } | |
| # Load all allowed patterns from settings files | |
| ALLOWED_PATTERNS="" | |
| ALLOWED_PATTERNS+=$(load_patterns_from_file "$HOME/.claude/settings.json") | |
| ALLOWED_PATTERNS+=$'\n' | |
| ALLOWED_PATTERNS+=$(load_patterns_from_file ".claude/settings.json") | |
| ALLOWED_PATTERNS+=$'\n' | |
| ALLOWED_PATTERNS+=$(load_patterns_from_file ".claude/settings.local.json") | |
| # Remove empty lines and duplicates | |
| ALLOWED_PATTERNS=$(echo "$ALLOWED_PATTERNS" | grep -v '^$' | sort -u) | |
| if [[ -z "$ALLOWED_PATTERNS" ]]; then | |
| # No patterns found, let Claude Code handle it normally | |
| exit 0 | |
| fi | |
| # Function to extract commands from a shell command string using shfmt | |
| extract_commands() { | |
| local cmd="$1" | |
| # Use shfmt to parse and extract command names | |
| # shfmt -tojson outputs an AST that we can parse | |
| echo "$cmd" | shfmt -tojson 2>/dev/null | jq -r ' | |
| # Recursive function to extract all command names from AST | |
| def extract_cmds: | |
| if type == "object" then | |
| if .Type == "CallExpr" then | |
| # Get the command name from Args[0] | |
| (if .Args and (.Args | length) > 0 then | |
| .Args[0] | | |
| if .Type == "Lit" then .Value | |
| elif .Type == "SglQuoted" then .Value | |
| elif .Type == "DblQuoted" then | |
| if .Parts and (.Parts | length) > 0 then | |
| .Parts[0].Value // empty | |
| else empty end | |
| else empty end | |
| else empty end), | |
| # Also recurse into Args to find subcommands | |
| (.Args // [] | .[1:] | .[] | extract_cmds) | |
| elif .Type == "BinaryCmd" then | |
| (.X | extract_cmds), (.Y | extract_cmds) | |
| elif .Type == "Subshell" then | |
| (.Stmts // [] | .[] | extract_cmds) | |
| elif .Type == "Block" then | |
| (.Stmts // [] | .[] | extract_cmds) | |
| elif .Type == "Stmt" then | |
| (.Cmd | extract_cmds) | |
| elif .Type == "File" then | |
| (.Stmts // [] | .[] | extract_cmds) | |
| else | |
| (.[] | extract_cmds) | |
| end | |
| elif type == "array" then | |
| .[] | extract_cmds | |
| else | |
| empty | |
| end; | |
| extract_cmds | |
| ' 2>/dev/null || { | |
| # Fallback: simple parsing if shfmt JSON parsing fails | |
| echo "$cmd" | tr '|;&' '\n' | sed 's/^[[:space:]]*//' | cut -d' ' -f1 | grep -v '^$' | |
| } | |
| } | |
| # Function to get full command with args for pattern matching | |
| extract_full_commands() { | |
| local cmd="$1" | |
| # Simple approach: split by pipe/chain operators and get each command | |
| echo "$cmd" | sed 's/&&/\n/g; s/||/\n/g; s/|/\n/g; s/;/\n/g' | while read -r subcmd; do | |
| # Trim whitespace and remove redirections for matching | |
| subcmd=$(echo "$subcmd" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/[0-9]*>[&]*[0-9]*//g; s/[0-9]*<//g') | |
| if [[ -n "$subcmd" ]]; then | |
| echo "$subcmd" | |
| fi | |
| done | |
| } | |
| # Function to check if a command matches any allowed pattern | |
| command_matches_pattern() { | |
| local cmd="$1" | |
| local cmd_name | |
| # Extract just the command name (first word) | |
| cmd_name=$(echo "$cmd" | awk '{print $1}') | |
| # Check against each pattern | |
| while IFS= read -r pattern; do | |
| [[ -z "$pattern" ]] && continue | |
| # Extract the pattern content from Bash(pattern) | |
| local pattern_content | |
| pattern_content=$(echo "$pattern" | sed 's/^Bash(//; s/)$//') | |
| # Handle different pattern formats: | |
| # 1. Bash(cmd:*) - matches cmd followed by anything | |
| # 2. Bash(cmd arg:*) - matches cmd arg followed by anything | |
| # 3. Bash(cmd) - exact match | |
| if [[ "$pattern_content" == *":*" ]]; then | |
| # Wildcard pattern - remove :* and check prefix | |
| local prefix="${pattern_content%:*}" | |
| if [[ "$cmd" == "$prefix"* || "$cmd" == "$prefix "* ]]; then | |
| return 0 | |
| fi | |
| elif [[ "$pattern_content" == *"*" ]]; then | |
| # Glob pattern | |
| local prefix="${pattern_content%\*}" | |
| if [[ "$cmd" == $prefix* ]]; then | |
| return 0 | |
| fi | |
| else | |
| # Exact match | |
| if [[ "$cmd" == "$pattern_content" || "$cmd_name" == "$pattern_content" ]]; then | |
| return 0 | |
| fi | |
| fi | |
| done <<< "$ALLOWED_PATTERNS" | |
| return 1 | |
| } | |
| # Extract all sub-commands from the input | |
| SUBCMDS=$(extract_full_commands "$COMMAND") | |
| if [[ -z "$SUBCMDS" ]]; then | |
| # No subcommands extracted, let Claude Code handle it normally | |
| exit 0 | |
| fi | |
| # Check if ALL sub-commands match allowed patterns | |
| ALL_MATCH=true | |
| while IFS= read -r subcmd; do | |
| [[ -z "$subcmd" ]] && continue | |
| if ! command_matches_pattern "$subcmd"; then | |
| ALL_MATCH=false | |
| break | |
| fi | |
| done <<< "$SUBCMDS" | |
| if [[ "$ALL_MATCH" == "true" ]]; then | |
| # All commands match - approve automatically | |
| echo '{"decision": "approve", "reason": "All piped commands match allowed patterns"}' | |
| exit 0 | |
| fi | |
| # Not all commands match - let Claude Code handle it normally (will prompt user) | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment