#!/usr/bin/env bash # PreToolUse guard for commands that reach a live host. # # Policy: reads stay friction-free, mutations ask first. # # The estate's widest privilege is that `agent` has passwordless sudo on prod and # beta, and the global settings allow `ssh prod:*` outright — so before this hook # existed, any session in any directory could roll, delete or restart production # with no prompt. CI cannot help here: nothing about `ssh prod 'docker service rm'` # goes through a pull request. # # Classification is an ALLOWLIST of read-only commands, not a blocklist of # dangerous ones. A blocklist is wrong by construction: the first destructive verb # nobody thought of sails straight through. Anything this script does not # positively recognise as a read becomes an "ask", which is the safe direction to # be wrong in. # # Exit 0 with no output = "no opinion", and the normal permission flow proceeds. # That is also what happens if this script errors or jq is missing, so a bug here # fails toward the prompt rather than toward silent execution. # # Beta is guarded as strictly as prod: it serves beta.thermograph.org AND hosts # Forgejo, so a destructive command there can take out the git server, CI and the # container registry at once. set -uo pipefail payload=$(cat) command -v jq >/dev/null 2>&1 || exit 0 tool=$(printf '%s' "$payload" | jq -r '.tool_name // ""') # An ssh target naming a live host, matched as a whole token after any `user@`. # Backslashes are doubled because awk -v processes escape sequences in the value # before the regex ever sees it; a single \. arrives as a bare dot (and warns). readonly HOST_TOKEN_RE='^(prod|beta|169\\.58\\.46\\.181|75\\.119\\.132\\.91|10\\.10\\.0\\.[12])$' emit() { # $1=allow|ask|deny $2=reason jq -n --arg d "$1" --arg r "$2" \ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$d,permissionDecisionReason:$r}}' exit 0 } # --- is this shell command read-only? ---------------------------------------- # Returns 0 only when every segment leads with a recognised read-only command. is_readonly() { local cmd="$1" seg w1 w2 stripped # Discard the harmless redirections that appear in ordinary read commands, # then treat any surviving redirect as a write. stripped=${cmd//2>&1/} stripped=${stripped//&>\/dev\/null/} stripped=${stripped//2>\/dev\/null/} stripped=${stripped//>\/dev\/null/} stripped=${stripped//> \/dev\/null/} case "$stripped" in *'>'*) return 1 ;; esac # Command substitution can hide anything; refuse to reason about it. # shellcheck disable=SC2016 # single quotes are the point: these are literal # `$(` and backtick characters being searched for inside the command text, not # expansions to perform. Expanding them here would defeat the check entirely. case "$cmd" in *'$('*|*'`'*) return 1 ;; esac while IFS= read -r seg; do seg="${seg#"${seg%%[![:space:]]*}"}" [ -z "$seg" ] && continue w1=$(printf '%s' "$seg" | awk '{print $1}') w2=$(printf '%s' "$seg" | awk '{print $2}') case "$w1" in cat|ls|head|tail|wc|grep|egrep|fgrep|rg|find|stat|df|du|free|uptime|whoami) ;; id|hostname|date|ps|env|printenv|pwd|which|uname|lsblk|ip|ss|netstat) ;; pg_isready|echo|true|sort|uniq|cut|tr|awk|jq|nproc|readlink|realpath|test) ;; sed) case "$seg" in *' -i'*) return 1 ;; esac ;; curl) case "$seg" in *' -X'*|*' -d'*|*'--data'*|*' -T'*|*' -F'*|*'--upload'*) return 1 ;; esac ;; journalctl) ;; systemctl) case "$w2" in status|show|is-active|is-enabled|is-failed|list-units|cat) ;; *) return 1 ;; esac ;; docker) case "$w2" in ps|logs|inspect|stats|images|version|info|top|port|events|history|diff) ;; service) case "$seg" in *'service ls'*|*'service ps'*|*'service inspect'*|*'service logs'*) ;; *) return 1 ;; esac ;; stack) case "$seg" in *'stack ls'*|*'stack ps'*|*'stack services'*) ;; *) return 1 ;; esac ;; compose) case "$seg" in *'compose ps'*|*'compose logs'*|*'compose config'*|*'compose top'*|*'compose images'*) ;; *) return 1 ;; esac ;; node) case "$seg" in *'node ls'*|*'node inspect'*) ;; *) return 1 ;; esac ;; volume) case "$seg" in *'volume ls'*|*'volume inspect'*) ;; *) return 1 ;; esac ;; *) return 1 ;; esac ;; git) case "$w2" in status|log|diff|show|branch|remote|rev-parse|describe|blame|ls-files|config) ;; *) return 1 ;; esac ;; *) return 1 ;; esac # NOTE: the trailing newline matters. Without it `read` returns non-zero on the # final (only) line, the loop body never runs, and every command classifies as # read-only — a silent false negative that allows anything. done < <(printf '%s\n' "$cmd" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g') return 0 } # --- find an ssh target and return what would run on it ----------------------- # Prints "\t" when a live host is addressed, nothing # otherwise. The remote command is empty for a bare login shell. ssh_target_of() { printf '%s' "$1" | awk -v re="$HOST_TOKEN_RE" ' { for (i = 1; i <= NF; i++) { t = $i; sub(/^[^@]*@/, "", t) if (t ~ re) { out = "" for (j = i + 1; j <= NF; j++) out = out (out == "" ? "" : " ") $j printf "%s\t%s\n", t, out exit } } }' } case "$tool" in Bash) cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // ""') case "$cmd" in *ssh*) ;; *) exit 0 ;; esac found=$(ssh_target_of "$cmd") [ -n "$found" ] || exit 0 # no live host addressed host=${found%%$'\t'*} remote=${found#*$'\t'} remote=$(printf '%s' "$remote" | sed -E "s/^'(.*)'$/\1/; s/^\"(.*)\"$/\1/") [ -z "$remote" ] && exit 0 # bare login shell if is_readonly "$remote"; then emit allow "read-only command on ${host}" fi emit ask "This mutates ${host}, a live host where the agent user has passwordless sudo. Command: ${remote}" ;; mcp__centralis__run_on_host) env=$(printf '%s' "$payload" | jq -r '.tool_input.env // ""') cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // ""') case "$env" in prod|beta) ;; *) exit 0 ;; esac if is_readonly "$cmd"; then emit allow "read-only command on ${env}" fi emit ask "run_on_host would mutate ${env}. Command: ${cmd}" ;; mcp__centralis__sql_query) env=$(printf '%s' "$payload" | jq -r '.tool_input.env // ""') write=$(printf '%s' "$payload" | jq -r '.tool_input.write // false') [ "$write" = "true" ] || exit 0 case "$env" in prod|beta) ;; *) exit 0 ;; esac sql=$(printf '%s' "$payload" | jq -r '.tool_input.sql // ""' | tr '\n' ' ' | cut -c1-300) emit ask "Write query against the ${env} database. sql_query escalates to the app role and has no confirmation of its own. SQL: ${sql}" ;; mcp__centralis__rollback_to|mcp__centralis__promote|mcp__centralis__secrets_rotate) emit ask "${tool#mcp__centralis__} changes production state directly." ;; *) exit 0 ;; esac