Last active
January 24, 2026 14:10
-
-
Save peterhartree/23ea4d9ded2c699b4c00357a48f1a9f4 to your computer and use it in GitHub Desktop.
Claude Code bash safety hook - blocks destructive commands, prompts for dangerous patterns
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 bun | |
| /** | |
| * Unified bash safety hook for Claude Code. | |
| * - Hard blocks destructive deletion commands (rm, shred, unlink, find -delete) | |
| * - Asks permission for other dangerous patterns (RCE, exfiltration, persistence, etc.) | |
| * | |
| * Exit codes: | |
| * - 0: Allow (or ask permission via JSON output) | |
| * - 2: Hard block with message | |
| */ | |
| interface ToolInput { | |
| tool_input?: { | |
| command?: string; | |
| }; | |
| } | |
| interface PermissionResult { | |
| needs: boolean; | |
| reason: string; | |
| } | |
| /** | |
| * Remove quoted strings to avoid false positives on commands like `echo 'rm test'`. | |
| */ | |
| function stripQuotes(command: string): string { | |
| // Remove double-quoted strings (handles escapes) | |
| let stripped = command.replace(/"(?:[^"\\]|\\.)*"/g, '""'); | |
| // Remove single-quoted strings (no escapes in single quotes) | |
| stripped = stripped.replace(/'[^']*'/g, "''"); | |
| return stripped; | |
| } | |
| /** | |
| * Check if command contains actual destructive deletion commands (not in quotes). | |
| * These are HARD BLOCKED - user prefers `trash` CLI instead. | |
| */ | |
| function isDestructiveCommand(command: string): boolean { | |
| const stripped = stripQuotes(command); | |
| // Allow git rm (safe, version-controlled) | |
| if (/\bgit\s+rm\b/.test(stripped)) { | |
| return false; | |
| } | |
| // Subshell patterns check the ORIGINAL command since the dangerous part is inside quotes | |
| const subshellPatterns = [ | |
| /\b(?:sh|bash|zsh|dash)\s+-c\s+.*\brm\b/, | |
| /\b(?:sh|bash|zsh|dash)\s+-c\s+.*\bshred\b/, | |
| /\b(?:sh|bash|zsh|dash)\s+-c\s+.*\bunlink\b/, | |
| /\b(?:sh|bash|zsh|dash)\s+-c\s+.*\bfind\b.*-delete\b/, | |
| ]; | |
| if (subshellPatterns.some((pattern) => pattern.test(command))) { | |
| return true; | |
| } | |
| // Patterns for rm/shred/unlink as actual commands: | |
| // - At start of command | |
| // - After shell operators: &&, ||, ;, |, $(, ` | |
| // - After sudo, xargs, command, env | |
| // - With absolute/relative paths like /bin/rm, /usr/bin/rm, ./rm | |
| const destructivePatterns = [ | |
| // Basic commands at start or after operators | |
| /(?:^|&&|\|\||;|\||\$\(|`)\s*rm\b/, | |
| /(?:^|&&|\|\||;|\||\$\(|`)\s*shred\b/, | |
| /(?:^|&&|\|\||;|\||\$\(|`)\s*unlink\b/, | |
| // Absolute/relative paths to rm | |
| /(?:^|&&|\|\||;|\||\$\(|`)\s*\/.*\/rm\b/, | |
| /(?:^|&&|\|\||;|\||\$\(|`)\s*\.\/rm\b/, | |
| // Via sudo, xargs, command, env | |
| /\bsudo\s+rm\b/, | |
| /\bsudo\s+\/.*\/rm\b/, | |
| /\bxargs\s+rm\b/, | |
| /\bxargs\s+\/.*\/rm\b/, | |
| /\bcommand\s+rm\b/, | |
| /\benv\s+rm\b/, | |
| // Backslash escape to bypass aliases | |
| /(?:^|&&|\|\||;|\||\$\(|`)\s*\\rm\b/, | |
| // find with -delete or -exec rm | |
| /\bfind\b.*\s-delete\b/, | |
| /\bfind\b.*-exec\s+rm\b/, | |
| /\bfind\b.*-exec\s+\/.*\/rm\b/, | |
| ]; | |
| return destructivePatterns.some((pattern) => pattern.test(stripped)); | |
| } | |
| /** | |
| * Check if a curl/wget command targets only local hosts (localhost, 127.0.0.1, etc.) | |
| * Local hosts are trusted since you control them - no remote code injection risk. | |
| */ | |
| function isLocalHostOnly(command: string): boolean { | |
| const cmdLower = command.toLowerCase(); | |
| // Extract URLs from curl/wget commands | |
| // Match http(s)://host patterns and bare host:port patterns | |
| const urlPattern = /(?:https?:\/\/)?([a-zA-Z0-9.-]+)(?::\d+)?/g; | |
| const networkCmdMatch = cmdLower.match(/(?:curl|wget)\s+[^|]+/); | |
| if (!networkCmdMatch) return false; | |
| const networkPart = networkCmdMatch[0]; | |
| const localPatterns = [ | |
| /\blocalhost\b/, | |
| /\b127\.0\.0\.1\b/, | |
| /\b0\.0\.0\.0\b/, | |
| /\b::1\b/, | |
| /\b\[::1\]\b/, | |
| ]; | |
| // If the network command contains a local host pattern, it's safe | |
| return localPatterns.some(pattern => pattern.test(networkPart)); | |
| } | |
| /** | |
| * Check if a netcat command targets localhost. | |
| * Netcat syntax: nc [options] host port | |
| */ | |
| function isNetcatLocalHost(command: string): boolean { | |
| const cmdLower = command.toLowerCase(); | |
| // Match nc/netcat followed by options and then a host | |
| // e.g., "nc localhost 8080", "nc -v 127.0.0.1 80", "netcat ::1 9000" | |
| const ncMatch = cmdLower.match(/\b(?:nc|netcat)\s+(?:-[a-z]+\s+)*(\S+)/); | |
| if (!ncMatch) return false; | |
| const host = ncMatch[1]; | |
| const localPatterns = [ | |
| /^localhost$/, | |
| /^127\.0\.0\.1$/, | |
| /^0\.0\.0\.0$/, | |
| /^::1$/, | |
| /^\[::1\]$/, | |
| ]; | |
| return localPatterns.some(pattern => pattern.test(host)); | |
| } | |
| /** | |
| * Check for dangerous patterns that should ask for permission (not hard block). | |
| */ | |
| function needsPermission(command: string): PermissionResult { | |
| const cmdLower = command.toLowerCase(); | |
| const cmdNormalised = command.split(/\s+/).join(" "); | |
| // === RCE: Piping downloads to shell === | |
| // Skip if targeting localhost - no remote code injection risk | |
| if (/(?:curl|wget)\s+.*\|\s*(?:bash|sh|zsh|python|python3|perl|ruby)/.test(cmdLower) && !isLocalHostOnly(command)) { | |
| return { needs: true, reason: "Piping download to shell (potential RCE)" }; | |
| } | |
| // Process substitution: bash <(curl ...) | |
| if (/(?:bash|sh|zsh)\s+<\s*\(\s*(?:curl|wget)/.test(cmdLower) && !isLocalHostOnly(command)) { | |
| return { needs: true, reason: "Process substitution with download (potential RCE)" }; | |
| } | |
| // eval $(curl ...), eval "$(curl ...)" | |
| if (/eval\s+.*\$\(.*\b(?:curl|wget)\b/.test(cmdLower) && !isLocalHostOnly(command)) { | |
| return { needs: true, reason: "Eval with download (potential RCE)" }; | |
| } | |
| // bash -c "$(curl ...)" | |
| if (/(?:bash|sh|zsh)\s+-c\s+.*\$\(.*\b(?:curl|wget)\b/.test(cmdLower) && !isLocalHostOnly(command)) { | |
| return { needs: true, reason: "Shell -c with download (potential RCE)" }; | |
| } | |
| // source <(curl ...) or source $(curl ...) | |
| if (/source\s+.*\$\(.*\b(?:curl|wget)\b/.test(cmdLower) && !isLocalHostOnly(command)) { | |
| return { needs: true, reason: "Source with download (potential RCE)" }; | |
| } | |
| // === Catastrophic deletion (extra layer beyond hard block) === | |
| const catastrophicPatterns = [ | |
| /rm\s+(?:-[a-z]*r[a-z]*\s+)*(?:-[a-z]*f[a-z]*\s+)*\/$/, | |
| /rm\s+(?:-[a-z]*r[a-z]*\s+)*(?:-[a-z]*f[a-z]*\s+)*\/\*/, | |
| /rm\s+(?:-[a-z]*r[a-z]*\s+)*(?:-[a-z]*f[a-z]*\s+)*~\/?$/, | |
| /rm\s+(?:-[a-z]*r[a-z]*\s+)*(?:-[a-z]*f[a-z]*\s+)*~\/\*/, | |
| /rm\s+(?:-[a-z]*r[a-z]*\s+)*(?:-[a-z]*f[a-z]*\s+)*\$HOME\/?$/, | |
| /rm\s+(?:-[a-z]*r[a-z]*\s+)*(?:-[a-z]*f[a-z]*\s+)*\$HOME\/\*/, | |
| /find\s+(?:\/|~|\$HOME)\s+.*-delete/, | |
| ]; | |
| for (const pattern of catastrophicPatterns) { | |
| if (pattern.test(cmdNormalised)) { | |
| return { needs: true, reason: "Catastrophic deletion (rm -rf on root or home)" }; | |
| } | |
| } | |
| // === Exfiltration: network commands with sensitive paths === | |
| const sensitivePaths = [ | |
| ".ssh", | |
| ".gnupg", | |
| ".aws", | |
| ".env", | |
| "credentials", | |
| "secrets", | |
| "private", | |
| "id_rsa", | |
| "id_ed25519", | |
| ".netrc", | |
| ".npmrc", | |
| "token", | |
| "api_key", | |
| "apikey", | |
| "password", | |
| ]; | |
| // Trusted domains exempt from exfiltration checks | |
| const trustedDomains = ["hartreeworks.org"]; | |
| const isTrustedDomain = trustedDomains.some((domain) => | |
| cmdLower.includes(domain) | |
| ); | |
| if (/(?:curl|wget|scp|rsync)/.test(cmdLower) && !isTrustedDomain && !isLocalHostOnly(command)) { | |
| for (const sensitive of sensitivePaths) { | |
| if (cmdLower.includes(sensitive)) { | |
| // Check if sending data | |
| if (/(?:-d|--data|--data-raw|--data-binary|-F|--form|@)/.test(cmdLower)) { | |
| return { needs: true, reason: `Network command with sensitive path '${sensitive}'` }; | |
| } | |
| } | |
| } | |
| } | |
| // Piping sensitive data to netcat | |
| if (/\b(?:nc|netcat)\b/.test(cmdLower) && !isNetcatLocalHost(command)) { | |
| for (const sensitive of sensitivePaths) { | |
| if (cmdLower.includes(sensitive)) { | |
| return { needs: true, reason: `Netcat with sensitive path '${sensitive}' (potential exfiltration)` }; | |
| } | |
| } | |
| // Piping anything to nc with external host (port number) | |
| if (/\|.*\b(?:nc|netcat)\b\s+\S+\s+\d+/.test(cmdLower)) { | |
| return { needs: true, reason: "Piping data to netcat (potential exfiltration)" }; | |
| } | |
| } | |
| // === Persistence: SSH authorized_keys modification === | |
| if (/authorized_keys/.test(cmdLower)) { | |
| if (/(?:>|>>|tee|echo.*>>|cat.*>)/.test(command)) { | |
| return { needs: true, reason: "Modification of SSH authorized_keys (persistence mechanism)" }; | |
| } | |
| } | |
| // === Persistence: crontab === | |
| if (/crontab\s+(?:-[a-z]*e|-)/.test(cmdLower)) { | |
| return { needs: true, reason: "Crontab modification (persistence mechanism)" }; | |
| } | |
| // === Persistence: launchd === | |
| if (/launchctl\s+(?:load|submit)/.test(cmdLower)) { | |
| return { needs: true, reason: "Launchctl load/submit (persistence mechanism)" }; | |
| } | |
| // === Persistence: shell profile modification === | |
| const shellProfiles = [".bashrc", ".bash_profile", ".zshrc", ".profile", ".zprofile"]; | |
| for (const profile of shellProfiles) { | |
| if (cmdLower.includes(profile)) { | |
| // Match write operations but exclude stderr/stdout redirection (2>, &>, >&, 2>&1) | |
| // The negative lookbehind (?<![0-9&]) ensures > isn't preceded by a digit or & | |
| if (/(?:(?<![0-9&])>>?(?![&])|tee|sed\s+-i|nano|vim?|vi\s)/.test(command)) { | |
| return { needs: true, reason: `Modification of shell profile '${profile}' (persistence mechanism)` }; | |
| } | |
| } | |
| } | |
| // === Privilege escalation: setuid === | |
| if (/chmod\s+\+[sS]/.test(command)) { | |
| return { needs: true, reason: "chmod +s (setuid - privilege escalation)" }; | |
| } | |
| if (/chmod\s+[0-7]*[4-7][0-7]{2}/.test(command)) { | |
| return { needs: true, reason: "chmod with setuid bit (privilege escalation)" }; | |
| } | |
| // === System file modification === | |
| if (/>\s*\/etc\//.test(command)) { | |
| return { needs: true, reason: "Redirect to /etc/ (system file modification)" }; | |
| } | |
| if (/tee\s+\/etc\//.test(command)) { | |
| return { needs: true, reason: "tee to /etc/ (system file modification)" }; | |
| } | |
| return { needs: false, reason: "" }; | |
| } | |
| async function main(): Promise<void> { | |
| try { | |
| const input = await Bun.stdin.text(); | |
| const data: ToolInput = JSON.parse(input); | |
| const command = data.tool_input?.command ?? ""; | |
| if (!command) { | |
| process.exit(0); | |
| } | |
| // Hard block destructive commands | |
| if (isDestructiveCommand(command)) { | |
| console.error( | |
| "BLOCKED: Do not use destructive file deletion commands " + | |
| "(rm, shred, unlink, find -delete). Use the 'trash' CLI instead:\n" + | |
| " - trash file.txt\n" + | |
| " - trash directory/\n\n" + | |
| "If trash is not installed:\n" + | |
| " - macOS: brew install trash\n" + | |
| " - Linux/npm: npm install -g trash-cli" | |
| ); | |
| process.exit(2); | |
| } | |
| // Ask permission for other dangerous patterns | |
| const { needs, reason } = needsPermission(command); | |
| if (needs) { | |
| console.log( | |
| JSON.stringify({ | |
| hookSpecificOutput: { | |
| hookEventName: "PreToolUse", | |
| permissionDecision: "ask", | |
| permissionDecisionReason: `Potentially dangerous command detected: ${reason}\n\nCommand: ${command}`, | |
| }, | |
| }) | |
| ); | |
| } | |
| process.exit(0); | |
| } catch { | |
| // Parse errors allow through (fail open) | |
| process.exit(0); | |
| } | |
| } | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment