[Bug] Claude Code bypasses CLAUDE.md restrictions and security tool constraints
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.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗