Skip to content

Instantly share code, notes, and snippets.

@peterhartree
Last active January 24, 2026 14:10
Show Gist options
  • Select an option

  • Save peterhartree/23ea4d9ded2c699b4c00357a48f1a9f4 to your computer and use it in GitHub Desktop.

Select an option

Save peterhartree/23ea4d9ded2c699b4c00357a48f1a9f4 to your computer and use it in GitHub Desktop.
Claude Code bash safety hook - blocks destructive commands, prompts for dangerous patterns
#!/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