#!/usr/bin/env bash # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ##@Version : 202604281009-git # @@Author : Jason Hempstead # @@Contact : git-admin@casjaysdev.pro # @@ReadME : protect-host.sh --help # @@Copyright : Copyright: (c) 2026 Jason Hempstead, Casjays Developments # @@Created : Friday, May 01, 2026 10:22 EDT # @@File : protect-host.sh # @@Description : Claude Code PreToolUse hook - block destructive Bash ops on host system paths # @@Changelog : Initial version # @@TODO : See project issues # @@Other : Container-mediated commands (docker/incus/podman/kubectl exec) are exempted # @@Resource : github.com/casapps/claude-code-hooks # @@Terminal App : no # @@sudo/root : no # @@Template : bash/simple # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # shellcheck disable=SC1001,SC1003,SC2001,SC2003,SC2016,SC2031,SC2090,SC2115,SC2120,SC2155,SC2199,SC2229,SC2317,SC2329 # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - VERSION="202602020740-git" # - - - - - - - - - - - - - - - - - - - - - - - - - set -uo pipefail # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Globals # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Protected host-path prefixes. Anything under these roots is off limits to destructive ops on the host. Inside containers, anything goes. PROTECTED_PATHS='(/|/bin|/sbin|/usr|/etc|/lib|/lib32|/lib64|/boot|/var|/root|/opt|/dev|/proc|/sys|/srv|/run)' # Destructive verbs that, paired with a protected path argument, get blocked. DESTRUCTIVE_VERBS='rm|rmd|rmdir|chmod|chown|chgrp|shred|truncate|wipefs' # Move/copy/link verbs that, when writing INTO a protected path, get blocked. WRITE_VERBS='mv|cp|install|ln' # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Helpers # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # __require_cmd - bail with a clear error if a required tool # is missing. A broken hook exits 0 (no-op) so we never silently # block every Bash call. __require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then printf 'protect-host.sh: required command not found: %s\n' "$1" >&2 exit 0 fi } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # __extract_command - read the JSON payload on stdin, print tool_input.command from the Claude Code PreToolUse event. __extract_command() { python3 -c ' import json, sys try: d = json.load(sys.stdin) print(d.get("tool_input", {}).get("command", "")) except Exception: print("") ' } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # __is_container_mediated - 0 if the command is mediated through a container/sandbox runtime (docker/incus/etc.). __is_container_mediated() { case "$1" in "docker exec "* | "docker run "*) return 0 ;; "docker compose exec "* | "docker compose run "*) return 0 ;; "docker-compose exec "* | "docker-compose run "*) return 0 ;; "incus exec "* | "incus shell "*) return 0 ;; "lxc exec "*) return 0 ;; "podman exec "* | "podman run "*) return 0 ;; "kubectl exec "*) return 0 ;; "nsenter "* | "chroot "*) return 0 ;; esac return 1 } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # __block - emit a structured BLOCKED message and exit 2. __block() { printf 'BLOCKED: %s\n' "$1" >&2 printf "Use 'docker exec', 'incus exec', or work inside a VM for destructive ops.\n" >&2 exit 2 } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # __match - return 0 if "$CMD" matches the extended regex. __match() { printf '%s' "$CMD" | grep -qE "$1" } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - __require_cmd python3 __require_cmd grep # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - INPUT="$(cat)" CMD="$(printf '%s' "$INPUT" | __extract_command)" # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Empty / non-Bash payload - nothing to inspect. [ -z "$CMD" ] && exit 0 # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Container-mediated commands are explicitly trusted at this layer. if __is_container_mediated "$CMD"; then exit 0 fi # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Word-boundary fragment reused across rules. Matches the start of a logical command position: line start, whitespace, or a shell separator. WORD_START='(^|[[:space:];|&`(])' # Up to 8 non-flag, non-operator tokens between a verb and its target path, so things like 'chmod -R 777 /etc' or 'find -L /etc -delete' match. TOKEN_GAP='([[:space:]]+[^[:space:]&|;()`<>]+){0,8}' # Tail fragment that anchors a protected path as a real argument boundary (followed by whitespace, slash, or end of string). PATH_TAIL="${PROTECTED_PATHS}([[:space:]/]|\$)" # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Rule 1: destructive verb followed (eventually) by a protected path. if __match "${WORD_START}(${DESTRUCTIVE_VERBS})${TOKEN_GAP}[[:space:]]+${PATH_TAIL}"; then __block "destructive command targeting host system path" fi # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Rule 2: shell redirect (> or >>) that truncates / appends a protected file. # Exempts /dev/null, /dev/std{in,out,err}, /dev/tty, /dev/fd/N, /dev/pts/N — these are # I/O pseudo-devices, not real files. Walks every redirect target via python and blocks # only if a target is under a protected root AND not a safe pseudo-device. __check_redirects() { python3 - "$1" <<'PYSCRIPT' import re, sys cmd = sys.argv[1] protected = re.compile(r"^(/|/bin|/sbin|/usr|/etc|/lib|/lib32|/lib64|/boot|/var|/root|/opt|/dev|/proc|/sys|/srv|/run)(/|$)") safe = re.compile(r"^/dev/(null|stdin|stdout|stderr|tty|fd/\d+|pts/\d+)$") for m in re.finditer(r"(?:^|[\s;|&`(])(?:\d+|&)?>>?\s*([^\s;|&`()<>]+)", cmd): target = m.group(1).strip("\"'") if protected.match(target) and not safe.match(target): print(target) sys.exit(1) sys.exit(0) PYSCRIPT } if BAD_REDIRECT="$(__check_redirects "$CMD")"; then : # all redirects safe else __block "shell redirect to host system path: $BAD_REDIRECT" fi # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Rule 3: 'find ... -delete' or '... -exec rm ...'. if __match "${WORD_START}find[[:space:]]+${PROTECTED_PATHS}([[:space:]/].*-delete|.*-exec[[:space:]]+rm)"; then __block "'find -delete' / '-exec rm' targeting host system path" fi # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Rule 4: write-verb (mv/cp/install/ln) with a protected destination. if __match "${WORD_START}(${WRITE_VERBS})([[:space:]]+[^[:space:]&|;()\`<>]+){1,8}[[:space:]]+${PATH_TAIL}"; then __block "mv/cp/install/ln with host system path destination" fi # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Rule 5: raw block-device writers targeting host disks (sda/hda/nvme/of=). if __match "${WORD_START}(dd|mkfs[^[:space:]]*)[[:space:]].*((of|of=)/dev/|[[:space:]]/dev/[sh]d|[[:space:]]/dev/nvme)"; then __block "raw disk writer targeting host device" fi # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Rule 6: 'cd / && rm ...' style cwd-shift attempts. if __match "${WORD_START}cd[[:space:]]+${PROTECTED_PATHS}([[:space:]/][^&;|]*)?[[:space:]]*(&&|;|\|\|)[[:space:]]*(${DESTRUCTIVE_VERBS}|>|>>)"; then __block "'cd' into host system path followed by destructive op" fi # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - exit 0