156 lines
7 KiB
Bash
Executable file
156 lines
7 KiB
Bash
Executable file
#!/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 "<host>\t<remote command>" 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
|