#!/usr/bin/env bash # PreToolUse hook for the Bash tool. Does NOT silently block anything — # per design, it only ever forces the normal confirmation prompt # (permissionDecision: "ask") when a Bash command contains shell chaining # operators. It never returns "deny" and never returns "allow" itself; it # either stays out of the way (no JSON output) or asks. This exists as a # safety net against Claude Code's Bash allow-list not always splitting # compound commands the way the docs describe (see e.g. # https://github.com/anthropics/claude-code/issues/20085), so a chained # command can't slip through on a whitelisted prefix without the user # seeing it. # # Register in settings.json under hooks.PreToolUse with a matcher of "Bash". set -euo pipefail INPUT="$(cat)" if ! command -v jq >/dev/null 2>&1; then # No jq available — fail open (no output = no opinion), rather than # breaking every Bash call because a dependency is missing. exit 0 fi TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // empty')" if [ "$TOOL_NAME" != "Bash" ]; then exit 0 fi CMD="$(echo "$INPUT" | jq -r '.tool_input.command // empty')" # Look for shell chaining/substitution operators anywhere in the command. # Deliberately broad (better a false positive prompt than a missed chain): # && || ; | backticks $( ) if echo "$CMD" | grep -qE '[;&|`]|\$\('; then jq -n \ --arg reason "Command appears to chain multiple shell commands (&&, ;, |, or command substitution). Forcing a normal confirmation prompt instead of relying on the allow-list, since compound commands can bypass per-command whitelisting." \ '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: $reason } }' fi exit 0