Skip to content

Instantly share code, notes, and snippets.

@JoeApo108
Created January 14, 2026 09:49
Show Gist options
  • Select an option

  • Save JoeApo108/2d03dc9dbc3bdcc5317e8b0e788ca6a1 to your computer and use it in GitHub Desktop.

Select an option

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)
#!/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