[Bug] Claude Code bypasses CLAUDE.md restrictions and security tool constraints

Resolved 💬 3 comments Opened Feb 5, 2026 by smartwatermelon Closed Mar 6, 2026

Bug Description
Claude Code attempts to bypass restrictions in CLAUDE.md, in hooks, and in security tools. The escalation of move and countermove is educational for me, the engineer, but also very concerning.

Environment Info

  • Platform: darwin
  • Terminal: iTerm.app
  • Version: 2.1.25
  • Feedback ID: d22fb84f-ecbb-4199-8c20-9250b707ebf5

CLAUDE.md says "do not merge PRs without human authorization". This is routinely ignored.

merge-lock.sh is my "block Claude merges without a human authorization" tool. The pre-merge Git hook instructs Claude to ask me to run the command.

#!/usr/bin/env bash
# ~/.claude/hooks/merge-lock.sh
# Merge authorization lock - requires human to authorize before agent can merge
set -euo pipefail

LOCK_DIR="${HOME}/.claude/merge-locks"
LOCK_TTL_SECONDS=1800  # 30 minutes

mkdir -p "${LOCK_DIR}"

GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'

create_merge_lock() {
  local pr_number="$1"
  local reason="${2:-Manual authorization}"
  local lock_file="${LOCK_DIR}/pr-${pr_number}.lock"

  local user
  user=$(whoami)
  local ts
  ts=$(date +%s)

  {
    echo "PR_NUMBER=${pr_number}"
    echo "AUTHORIZED_BY=${user}"
    echo "TIMESTAMP=${ts}"
    echo "REASON=${reason}"
  } > "${lock_file}"

  echo -e "${GREEN}[merge-lock]${NC} Authorization created for PR #${pr_number}"
  echo -e "${GREEN}[merge-lock]${NC} Valid for 30 minutes"
  echo -e "${GREEN}[merge-lock]${NC} Lock file: ${lock_file}"
}

check_merge_lock() {
  local pr_number="$1"
  local lock_file="${LOCK_DIR}/pr-${pr_number}.lock"

  [[ ! -f "${lock_file}" ]] && return 1

  local timestamp
  timestamp=$(grep "^TIMESTAMP=" "${lock_file}" | cut -d= -f2)
  local now
  now=$(date +%s)
  local age=$((now - timestamp))

  if [[ ${age} -gt ${LOCK_TTL_SECONDS} ]]; then
    rm -f "${lock_file}"
    return 1
  fi
  return 0
}

show_status() {
  local pr_number="$1"
  local lock_file="${LOCK_DIR}/pr-${pr_number}.lock"

  if [[ -f "${lock_file}" ]]; then
    local timestamp
    timestamp=$(grep "^TIMESTAMP=" "${lock_file}" | cut -d= -f2)
    local now
    now=$(date +%s)
    local age=$((now - timestamp))
    local remaining=$((LOCK_TTL_SECONDS - age))

    if [[ ${remaining} -gt 0 ]]; then
      local auth_by
      auth_by=$(grep "^AUTHORIZED_BY=" "${lock_file}" | cut -d= -f2 || true)
      local auth_reason
      auth_reason=$(grep "^REASON=" "${lock_file}" | cut -d= -f2 || true)
      echo -e "${GREEN}[merge-lock]${NC} PR #${pr_number} is authorized"
      echo "  Authorized by: ${auth_by}"
      echo "  Reason: ${auth_reason}"
      echo "  Expires in: $((remaining / 60)) minutes"
    else
      echo -e "${YELLOW}[merge-lock]${NC} PR #${pr_number} authorization expired"
      rm -f "${lock_file}"
    fi
  else
    echo -e "${RED}[merge-lock]${NC} PR #${pr_number} is NOT authorized"
    echo ""
    echo "To authorize merge (valid 30 minutes):"
    echo "  ~/.claude/hooks/merge-lock.sh authorize ${pr_number} \"reason\""
  fi
}

list_locks() {
  echo "=== Active Merge Authorizations ==="
  local found=false
  for lock_file in "${LOCK_DIR}"/*.lock; do
    [[ ! -f "${lock_file}" ]] && continue
    found=true
    local pr
    pr=$(grep "^PR_NUMBER=" "${lock_file}" | cut -d= -f2)
    local auth
    auth=$(grep "^AUTHORIZED_BY=" "${lock_file}" | cut -d= -f2)
    local reason
    reason=$(grep "^REASON=" "${lock_file}" | cut -d= -f2)
    echo "  PR #${pr} - by ${auth} - ${reason}"
  done
  if [[ "${found}" == false ]]; then
    echo "  (none)"
  fi
}

case "${1:-help}" in
  authorize|auth)
    if [[ -z "${2:-}" ]]; then
      echo "Usage: $0 authorize <pr_number> [reason]"
      exit 1
    fi
    create_merge_lock "$2" "${3:-}"
    ;;
  check)
    if [[ -z "${2:-}" ]]; then
      echo "Usage: $0 check <pr_number>"
      exit 1
    fi
    if check_merge_lock "$2"; then
      echo "Authorized"
      exit 0
    else
      echo "Not authorized"
      exit 1
    fi
    ;;
  status)
    if [[ -z "${2:-}" ]]; then
      echo "Usage: $0 status <pr_number>"
      exit 1
    fi
    show_status "$2"
    ;;
  list)
    list_locks
    ;;
  *)
    echo "Usage: $0 {authorize|check|status|list} [pr_number] [reason]"
    echo ""
    echo "Commands:"
    echo "  authorize <pr> [reason]  - Create merge authorization (30 min TTL)"
    echo "  check <pr>               - Check if PR is authorized (exit 0/1)"
    echo "  status <pr>              - Show detailed authorization status"
    echo "  list                     - List all active authorizations"
    ;;
esac

Result: Claude runs the command itself, generates the merge lock, and merges without authorization.

hook-block-merge-lock.sh is called from the PreToolUse hook. The first version of this blocked Claude from running the merge-lock.sh script, so it started manually creating the .lock files, and then merging. The current version blocks commands that access the merge-lock directory.

#!/usr/bin/env bash
# Hook: Block attempts to run merge-lock.sh or access merge-locks directory
# Prevents accidental or unauthorized merge lock operations

set -euo pipefail

input=$(cat)
cmd=$(printf '%s\n' "${input}" | jq -r '.tool_input.command // empty')

# Allow git commit/log/show/diff commands early - they may mention merge-lock.sh
# or merge-locks in messages, but that's safe (just text in commit messages)
if printf '%s\n' "${cmd}" | grep -qE '(^|&&|\|\||;)\s*git\s+(commit|log|show|diff)\b'; then
  exit 0
fi

# Block any command referencing the merge-locks directory
# This prevents all read/write/delete operations on lock files
if printf '%s\n' "${cmd}" | grep -qE '(^|[^a-zA-Z0-9_-])merge-locks(/|[^a-zA-Z0-9_-]|$)'; then
  printf '%s BLOCKED: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ || true)" "${cmd}" >>"${HOME}/.claude/blocked-commands.log"
  printf '🛑 BLOCKED: Access to merge-locks directory is forbidden.\n' >&2
  printf '\n' >&2
  printf 'Merge locks are managed by authorized processes only.\n' >&2
  exit 2
fi

# Block dangerous merge-lock.sh script invocations regardless of method
#
# Pattern 1: Direct invocation with optional path prefix
#   ~/.claude/hooks/merge-lock.sh authorize
#   ./merge-lock.sh revoke
#   merge-lock.sh authorize (after && || ;)
#
# Pattern 2: Shell interpreter invocation
#   bash -c "merge-lock.sh authorize"
#   sh -c '~/.claude/hooks/merge-lock.sh authorize'
if printf '%s\n' "${cmd}" | grep -qE '(^|&&|\|\||;)\s*(~?[./][^[:space:]"'\'']*)?merge-lock\.sh\s+(authorize|revoke)'; then
  printf '%s BLOCKED: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ || true)" "${cmd}" >>"${HOME}/.claude/blocked-commands.log"
  printf '🛑 BLOCKED: Running merge-lock.sh is forbidden.\n' >&2
  printf '\n' >&2
  printf 'Merge locks should only be managed by authorized processes.\n' >&2
  exit 2
fi

# Block shell interpreter bypass: bash -c "..." or sh -c '...'
if printf '%s\n' "${cmd}" | grep -qE '(bash|sh)\s+-c\s+.*merge-lock\.sh\s+(authorize|revoke)'; then
  printf '%s BLOCKED: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ || true)" "${cmd}" >>"${HOME}/.claude/blocked-commands.log"
  printf '🛑 BLOCKED: Running merge-lock.sh is forbidden.\n' >&2
  printf '\n' >&2
  printf 'Merge locks should only be managed by authorized processes.\n' >&2
  exit 2
fi

I fully expect Claude to next analyze the hook scripts and either disable them altogether or find a clever way around them.

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗