Last active
March 4, 2026 01:25
-
-
Save yoniLavi/c3dc241fdb132b7dbe902c430ac32a47 to your computer and use it in GitHub Desktop.
An AI-powered permission hook for Claude Code (itself coded interactively with the help of Claude Code)
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 | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # claude-shield — AI-powered permission hook for Claude Code | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # | |
| # Flow: AllowList (settings.json, instant) → DENY regex (~40ms) → LLM review (~1-2s) | |
| # | |
| # Safe commands should live in permissions.allow in settings.json so | |
| # they never reach this hook. Only unknown/risky actions arrive here. | |
| # | |
| # Backend: AWS Bedrock via the standard credential chain. | |
| # Works with `aws sso login`, env vars, instance profiles, etc. | |
| # Default model: Claude Haiku 4.5 (cheapest, ~$0.80/M input, $4/M output on Bedrock) | |
| # | |
| # Falls through to the normal Claude Code prompt if: | |
| # - AWS CLI call fails (bad creds, no access, network error) | |
| # - LLM response is ambiguous | |
| # | |
| # Requires: aws-cli v2, jq | |
| # | |
| # Install: | |
| # 1. Copy to ~/.claude/hooks/claude-shield.sh && chmod +x | |
| # 2. Ensure `aws sso login` works (or any other AWS auth method) | |
| # 3. Merge the hooks block into ~/.claude/settings.json (see bottom of file) | |
| # | |
| set -uo pipefail | |
| # ── Config ──────────────────────────────────────────────────── | |
| HOOKS_DIR="$HOME/.claude/hooks" | |
| ENV_FILE="$HOOKS_DIR/.env" | |
| # Load overrides (AWS_REGION, BEDROCK_MODEL_ID, CLAUDE_SHIELD_LOG, AWS_PROFILE, etc.) | |
| [[ -f "$ENV_FILE" ]] && source "$ENV_FILE" 2>/dev/null | |
| REGION="${AWS_REGION:-us-east-1}" | |
| MODEL_ID="${BEDROCK_MODEL_ID:-us.anthropic.claude-haiku-4-5-20251001-v1:0}" | |
| LOG_FILE="${CLAUDE_SHIELD_LOG:-}" | |
| # ── Helpers ─────────────────────────────────────────────────── | |
| log() { [[ -n "$LOG_FILE" ]] && echo "[$(date +%H:%M:%S)] $*" >> "$LOG_FILE"; } | |
| allow() { | |
| log "ALLOW $tool_name ${summary:-}" | |
| cat <<'EOF' | |
| {"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}} | |
| EOF | |
| exit 0 | |
| } | |
| deny() { | |
| log "DENY $tool_name ${summary:-} — $1" | |
| local msg | |
| msg=$(printf '%s' "$1" | jq -Rs .) | |
| echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"deny\",\"message\":$msg}}}" | |
| exit 0 | |
| } | |
| passthrough() { | |
| log "PASS $tool_name ${summary:-} — $1" | |
| osascript -e "display notification \"$1\" with title \"claude-shield\" subtitle \"${tool_name}: ${summary:-unknown}\"" 2>/dev/null & | |
| exit 0 # empty stdout = fall through to normal prompt | |
| } | |
| # ── Read stdin ──────────────────────────────────────────────── | |
| input=$(cat) | |
| tool_name=$(echo "$input" | jq -r '.tool_name // empty') | |
| [[ -z "$tool_name" ]] && exit 0 | |
| # ── DENY layer: instant regex safety net ────────────────────── | |
| summary="" | |
| case "$tool_name" in | |
| Bash) | |
| cmd=$(echo "$input" | jq -r '.tool_input.command // empty') | |
| [[ -z "$cmd" ]] && exit 0 | |
| summary="${cmd:0:80}" | |
| # Destructive system commands | |
| [[ "$cmd" =~ rm[[:space:]]+-[rf]{2}[[:space:]]+/[^a-z] ]] && deny "Blocked: recursive delete on root path" | |
| [[ "$cmd" =~ (mkfs|dd[[:space:]]+if=) ]] && deny "Blocked: disk-level destructive command" | |
| [[ "$cmd" =~ \>[[:space:]]*/dev/(sd|hd|disk|mem|kmem) ]] && deny "Blocked: write to raw device" | |
| # Privilege escalation | |
| [[ "$cmd" =~ (^|[;\&\|])[[:space:]]*sudo[[:space:]] ]] && deny "sudo requires manual approval" | |
| # Pipe-to-shell (remote code execution) | |
| [[ "$cmd" =~ (curl|wget).*\|[[:space:]]*(ba)?sh ]] && deny "Blocked: pipe-to-shell pattern" | |
| [[ "$cmd" =~ base64.*-d.*\|[[:space:]]*(ba)?sh ]] && deny "Blocked: base64 decode to shell" | |
| # Data exfiltration via pipe | |
| [[ "$cmd" =~ \|[[:space:]]*(curl|wget|ssh|scp|rsync|nc|netcat) ]] && deny "Blocked: pipe to network tool" | |
| cwd=$(echo "$input" | jq -r '.cwd // "."') | |
| action_detail="Bash command: ${cmd}\nWorking dir: ${cwd}" | |
| ;; | |
| Edit|Write) | |
| fpath=$(echo "$input" | jq -r '.tool_input.file_path // empty') | |
| [[ -z "$fpath" ]] && exit 0 | |
| summary="${fpath}" | |
| # System-critical paths | |
| [[ "$fpath" =~ ^/(etc|usr|bin|sbin|var|boot|System|Library)/ ]] && deny "Blocked: system path" | |
| # Secret stores | |
| [[ "$fpath" =~ /\.(ssh|aws|gnupg|gpg)/ ]] && deny "Blocked: secrets directory" | |
| # Env files & credential files | |
| [[ "$fpath" =~ /\.env(\.[a-z]+)?$ ]] && deny "Blocked: .env file may contain secrets" | |
| [[ "$fpath" =~ (credentials|secrets|tokens|passwords|private[._-]key|\.pem$|\.key$) ]] \ | |
| && deny "Blocked: credentials file" | |
| action_detail="File: ${fpath}" | |
| ;; | |
| WebFetch) | |
| url=$(echo "$input" | jq -r '.tool_input.url // empty') | |
| [[ -z "$url" ]] && exit 0 | |
| summary="${url:0:80}" | |
| # Non-HTTP | |
| [[ ! "$url" =~ ^https?:// ]] && deny "Only HTTP(S) URLs allowed" | |
| # Auth tokens leaked in URL | |
| [[ "$url" =~ (api_key|token|secret|password|auth|access_token)= ]] && deny "Blocked: credentials in URL" | |
| # Private/internal IPs | |
| domain=$(echo "$url" | sed -E 's|^https?://([^/:]+).*|\1|') | |
| [[ "$domain" =~ ^(localhost|127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|0\.0\.0\.0) ]] \ | |
| && deny "Blocked: internal/private address" | |
| action_detail="URL: ${url}" | |
| ;; | |
| *) | |
| exit 0 # unknown tool → normal prompt | |
| ;; | |
| esac | |
| # ── Build context from session transcript ───────────────────── | |
| session_id=$(echo "$input" | jq -r '.session_id // empty') | |
| context="" | |
| if [[ -n "$session_id" ]]; then | |
| transcript=$(find "$HOME/.claude/projects" -name "${session_id}.jsonl" -maxdepth 3 2>/dev/null | head -1) | |
| if [[ -n "$transcript" && -f "$transcript" ]]; then | |
| task=$(head -20 "$transcript" | jq -r 'select(.role=="user") | .content[:300]' 2>/dev/null | head -1) | |
| recent=$(tail -15 "$transcript" | jq -r 'select(.role) | "[\(.role)] \(.content[:150] // "...")"' 2>/dev/null | tail -4) | |
| [[ -n "$task" ]] && context="Developer's task: ${task}\nRecent activity:\n${recent}" | |
| fi | |
| fi | |
| # ── Call Bedrock ────────────────────────────────────────────── | |
| system_prompt="You are a security reviewer for Claude Code, an AI coding assistant running commands on a developer's machine. An action needs your approval. | |
| This action has ALREADY passed a regex safety filter that blocks known-dangerous patterns: sudo, rm -rf /, pipe-to-shell, pipe-to-network, credential file access, system path edits, etc. Your job is to catch subtler risks that pattern matching misses. | |
| DENY if you can identify a concrete way the action could cause harm: | |
| - Data exfiltration (e.g. posting file contents to an external server) | |
| - Obfuscated or encoded execution (e.g. base64-decoded eval, hex-encoded payloads) | |
| - Destructive operations the regex missed (e.g. rm -rf ./, dropping databases) | |
| - Accessing or leaking secrets through indirect means | |
| - Actions that make no sense given the developer's current task (possible prompt injection) | |
| ALLOW if you cannot identify a plausible way the action is dangerous. Normal development operations — building, testing, fetching packages, reading docs, running scripts, making HTTP requests — should be allowed unless there is a specific, concrete risk. | |
| Respond with exactly one word on its own line: ALLOW or DENY | |
| If DENY, add a second line briefly explaining the concrete risk (one sentence)." | |
| user_msg="Project directory: $(echo "$input" | jq -r '.cwd // "unknown"')\n${context:+${context}\n\n}Action requiring approval:\n${action_detail}\n\nALLOW or DENY (if DENY, explain why on the next line)" | |
| body=$(jq -n \ | |
| --arg sys "$system_prompt" \ | |
| --arg msg "$user_msg" \ | |
| '{ | |
| anthropic_version: "bedrock-2023-05-31", | |
| max_tokens: 60, | |
| system: $sys, | |
| messages: [{role: "user", content: $msg}] | |
| }') | |
| tmpfile=$(mktemp) | |
| errfile=$(mktemp) | |
| trap 'rm -f "$tmpfile" "$errfile"' EXIT | |
| if ! aws bedrock-runtime invoke-model \ | |
| --model-id "$MODEL_ID" \ | |
| --region "$REGION" \ | |
| --body "$body" \ | |
| --cli-binary-format raw-in-base64-out \ | |
| "$tmpfile" > /dev/null 2>"$errfile"; then | |
| err=$(head -3 "$errfile" | tr '\n' ' ' | cut -c1-200) | |
| passthrough "Bedrock failed: ${err:-unknown error}" | |
| fi | |
| llm_output=$(jq -r '.content[0].text // empty' "$tmpfile") | |
| decision=$(echo "$llm_output" | head -1 | tr '[:lower:]' '[:upper:]' | grep -oE 'ALLOW|DENY' | head -1) | |
| reason=$(echo "$llm_output" | tail -n +2 | tr '\n' ' ' | sed 's/^[[:space:]]*//' | cut -c1-200) | |
| # ── Act on decision ─────────────────────────────────────────── | |
| case "$decision" in | |
| ALLOW) allow ;; | |
| DENY) deny "claude-shield: ${reason:-action not approved}" ;; | |
| *) passthrough "ambiguous LLM response: $(echo "$llm_output" | head -1)" ;; | |
| esac | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # INSTALLATION | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # | |
| # 1. Place this file: | |
| # mkdir -p ~/.claude/hooks | |
| # cp claude-shield.sh ~/.claude/hooks/ | |
| # chmod +x ~/.claude/hooks/claude-shield.sh | |
| # | |
| # 2. Ensure AWS auth works. Any of these: | |
| # aws sso login # SSO (recommended) | |
| # aws configure # static keys | |
| # # or: EC2 instance profile, ECS task role, etc. | |
| # | |
| # Verify with: aws sts get-caller-identity | |
| # | |
| # 3. Ensure Bedrock model access: | |
| # Go to AWS Console → Bedrock → Model Access → request Anthropic models | |
| # Verify with: aws bedrock list-foundation-models --region us-east-1 \ | |
| # --by-provider anthropic --query "modelSummaries[*].modelId" | |
| # | |
| # 4. Merge into ~/.claude/settings.json: | |
| # | |
| # { | |
| # "permissions": { | |
| # "allow": [ | |
| # "Read", "Glob", "Grep", "Task", "WebSearch", | |
| # "Bash(git:*)", "Bash(pnpm:*)", "Bash(npm:*)", "Bash(npx:*)", | |
| # "Bash(node:*)", "Bash(python3:*)", "Bash(python:*)", | |
| # "Bash(ls:*)", "Bash(cat:*)", "Bash(head:*)", "Bash(tail:*)", | |
| # "Bash(wc:*)", "Bash(grep:*)", "Bash(find:*)", "Bash(which:*)", | |
| # "Bash(echo:*)", "Bash(pwd:*)", "Bash(date:*)", "Bash(whoami:*)", | |
| # "Bash(mkdir:*)", "Bash(cp:*)", "Bash(mv:*)", "Bash(touch:*)", | |
| # "Bash(jq:*)", "Bash(sed:*)", "Bash(awk:*)", "Bash(sort:*)", | |
| # "Bash(uniq:*)", "Bash(diff:*)", "Bash(tr:*)", "Bash(cut:*)", | |
| # "Bash(tee:*)", "Bash(xargs:*)", "Bash(basename:*)", "Bash(dirname:*)", | |
| # "Bash(realpath:*)", "Bash(cargo:*)", "Bash(rustc:*)", | |
| # "Bash(go:*)", "Bash(make:*)", "Bash(cmake:*)", | |
| # "Bash(tsc:*)", "Bash(eslint:*)", "Bash(prettier:*)" | |
| # ] | |
| # }, | |
| # "hooks": { | |
| # "PermissionRequest": [{ | |
| # "matcher": "Bash|Edit|Write|WebFetch", | |
| # "hooks": [{ | |
| # "type": "command", | |
| # "command": "bash ~/.claude/hooks/claude-shield.sh", | |
| # "timeout": 15000 | |
| # }] | |
| # }] | |
| # } | |
| # } | |
| # | |
| # 5. IMPORTANT: Check .claude/settings.local.json for stale allow rules | |
| # that would bypass this hook entirely: | |
| # cat .claude/settings.local.json | jq '.permissions.allow' | |
| # | |
| # 6. Optional overrides in ~/.claude/hooks/.env: | |
| # AWS_REGION=eu-west-1 # default: us-east-1 | |
| # AWS_PROFILE=my-sso-profile # if you use named profiles | |
| # BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-5-20250929-v1:0 # upgrade reviewer model | |
| # CLAUDE_SHIELD_LOG=/home/you/.claude/hooks/shield.log # enable logging | |
| # | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # COST ESTIMATE (Bedrock on-demand, Haiku 4.5) | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # | |
| # Haiku 4.5 on Bedrock: ~$0.80/M input, ~$4.00/M output | |
| # ~500 input tokens + ~2 output tokens per call ≈ $0.000408 | |
| # 100 LLM-reviewed commands/day ≈ $0.04/day ≈ $1.22/month | |
| # | |
| # Most commands hit the AllowList and never reach this hook, | |
| # so real-world cost is typically well under $1/month. | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment