From 3db8d4cf4b30c6f71836ed8c92c728d49700ee1b Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sat, 19 Sep 2026 20:36:47 +0200 Subject: [PATCH 1/2] Ask the user before a guest container or VM is stopped or deleted The guard gets a second tier next to deny. Stopping, shutting down or deleting a system container or VM through its manager (pct, qm, incus, lxc, virsh, lxc-stop, lxc-destroy) is answered with ask: the user confirms the exact command in a permission prompt. Where no prompt can reach a human (bypassPermissions, dontAsk, an unknown mode, and every claude -p run, measured with Claude Code 2.1.267) it is denied. Host taboos in the same command still win. A guest manager's shutdown verb no longer counts as a host power-off, so the clean way to stop a guest is no stricter than the hard one. Both decisions go through one emitter. The test matrix learns the permission mode and the ask outcome. Co-Authored-By: Claude Opus 5 --- .claude/hooks/guard-taboos-test.sh | 69 +++++++++++++++-- .claude/hooks/guard-taboos.sh | 114 ++++++++++++++++++++++++++--- rules/scheduled-housekeeping.md | 3 + 3 files changed, 170 insertions(+), 16 deletions(-) diff --git a/.claude/hooks/guard-taboos-test.sh b/.claude/hooks/guard-taboos-test.sh index 387b028..9f0cebb 100755 --- a/.claude/hooks/guard-taboos-test.sh +++ b/.claude/hooks/guard-taboos-test.sh @@ -10,25 +10,32 @@ PASS=0 FAIL=0 json_for() { - # Wrap a raw command string as PreToolUse hook input. + # Wrap a raw command string as PreToolUse hook input, with the + # permission mode $2 when given. if command -v jq >/dev/null 2>&1; then - printf '%s' "$1" \ - | jq -Rs '{tool_name:"Bash",tool_input:{command:.}}' + printf '%s' "$1" | jq -Rs --arg m "${2:-}" \ + '{tool_name:"Bash",tool_input:{command:.}} + + (if $m == "" then {} else {permission_mode:$m} end)' else printf '%s' "$1" | python3 -c 'import json,sys; \ -print(json.dumps({"tool_name":"Bash","tool_input":\ -{"command":sys.stdin.read()}}))' +d={"tool_name":"Bash","tool_input":{"command":sys.stdin.read()}}; \ +m=sys.argv[1]; d.update({"permission_mode":m} if m else {}); \ +print(json.dumps(d))' "${2:-}" fi } check() { + # check pass|deny|ask CMD [permission mode] EXPECT=$1 CMDSTR=$2 - OUT=$(json_for "$CMDSTR" \ + OUT=$(json_for "$CMDSTR" "${3:-}" \ | env -u HEINZEL_GUARD_DISABLE sh "$HOOK") if printf '%s' "$OUT" \ | grep -q '"permissionDecision":"deny"'; then GOT=deny + elif printf '%s' "$OUT" \ + | grep -q '"permissionDecision":"ask"'; then + GOT=ask else GOT=pass fi @@ -36,7 +43,7 @@ check() { PASS=$((PASS + 1)) else FAIL=$((FAIL + 1)) - echo "FAIL [$EXPECT, got $GOT]: $CMDSTR" + echo "FAIL [$EXPECT, got $GOT${3:+, mode $3}]: $CMDSTR" fi } @@ -231,6 +238,54 @@ check deny 'chown -R alice:alice /home/alice/.ssh && chmod 700 /home/alice/.ssh' check deny "python3 -c \"import shutil; shutil.rmtree('/root/.ssh')\"" check deny "node -e \"require('fs').chmodSync('/home/alice/.ssh', 0)\"" +# --- guest stop and delete: asked, not denied ------------------ +# The manager's own verb, in a mode whose prompt reaches a human. +check ask 'pct stop 105' default +check ask 'pct shutdown 105' default +check ask 'ssh root@pve1 "qm stop 100 --skiplock"' default +check ask 'qm shutdown 100' default +check ask 'pct destroy 105' default +check ask 'qm destroy 100 --purge' default +check ask 'incus stop web' default +check ask 'incus -q stop web' default +check ask 'incus --project prod stop web' default +check ask 'incus --project=prod delete web' default +check ask 'lxc --force-local stop web' default +check ask 'lxc delete -f web' default +check ask 'virsh shutdown web' default +check ask 'virsh destroy web' default +check ask 'virsh --connect qemu:///system destroy web' default +check ask 'virsh -c qemu:///system undefine web' default +check ask 'lxc-stop -n web' default +check ask 'lxc-destroy -n web' default +check ask 'pct stop 105 && pct destroy 105' default +for m in acceptEdits plan auto; do + check ask 'pct stop 105' "$m" +done +# No prompt can reach a human, or the mode is unknown: denied. +for m in bypassPermissions dontAsk bogus ''; do + check deny 'pct stop 105' "$m" +done +# A host taboo in the same command still wins over the ask, and +# a guest shutdown does not hide a host one next to it. +check deny 'pct stop 105; mkfs.ext4 /dev/sda1' default +check deny 'qm destroy 100 && reboot -f; halt' default +check deny 'pct shutdown 105; shutdown -h now' default +check deny 'ssh root@pve1 "qm shutdown 100 && shutdown now"' default +# Everything else a guest manager does stays allowed. +check pass 'pct list' default +check pass 'pct reboot 105' default +check pass 'qm reboot 100' default +check pass 'lxc-stop -n web -r' default +check pass 'incus restart web' default +check pass 'incus exec web -- systemctl stop nginx' default +check pass 'pct exec 105 -- rm /tmp/old.log' default +check pass 'incus snapshot delete web snap0' default +check pass 'pct delsnapshot 105 snap0' default +check pass 'virsh net-destroy default' default +check pass 'virsh vol-delete disk.qcow2 --pool default' default +check pass 'docker stop web' default + # --- same effect through an interpreter (issue #6) ------------- # The path rules recognized a write by the way it was spelled # (redirect, tee, sed -i, an editor, rm/mv/chmod). An interpreter diff --git a/.claude/hooks/guard-taboos.sh b/.claude/hooks/guard-taboos.sh index 3483482..bcd9c8e 100755 --- a/.claude/hooks/guard-taboos.sh +++ b/.claude/hooks/guard-taboos.sh @@ -2,7 +2,8 @@ # guard-taboos.sh — PreToolUse hook (matcher: Bash). # # Mechanically enforces heinzel's absolute taboos from -# CLAUDE.md → Critical Safety Rules, below the model layer: +# CLAUDE.md → Critical Safety Rules, below the model layer. +# DENIED outright: # # - halt / poweroff / shutdown without -r / init 0 / # telinit 0 / sysrq-trigger @@ -24,6 +25,15 @@ # runtime (python/perl/ruby/node/awk ...), whose file # I/O looks nothing like a shell write # +# ASKED, not denied — the user confirms the exact command in a +# permission prompt (see "Guest stop and delete" at the end for +# the membership criterion and the modes that deny instead): +# +# - stopping or deleting a system container or VM +# (pct/qm stop, shutdown or destroy, incus/lxc stop or +# delete, virsh shutdown/destroy/undefine, lxc-stop, +# lxc-destroy) +# # What it deliberately does NOT scan: the body of a heredoc that # is written to an ordinary file by cat or tee (issue #8). That # is documentation, not code. Every other heredoc — above all @@ -96,7 +106,8 @@ # # Being blocked is EXPECTED behavior. Explain it to the user. # Never rephrase, re-quote, or otherwise obfuscate a command to -# evade this guard. +# evade this guard. The same holds for an asked command: put it +# to the user as it stands, rather than in another spelling. # # Heinzel-repo development note: a commit message piped straight # into `git commit -m`/`-F -` still flows through the command @@ -129,6 +140,9 @@ if command -v jq >/dev/null 2>&1; then fi [ -n "$CMD" ] || CMD="$INPUT" +# Set when a guest stop or delete was seen; decided at the end. +GUEST_ASK="" + # --- Heredoc bodies that are DATA, not code ------------------- # Documentation is the one thing that legitimately contains taboo # words: a changelog entry describing a shutdown checkpoint, a @@ -340,18 +354,22 @@ hit_without() { return 0 } -deny() { - # JSON decision on stdout; blocks in all permission modes. +decide() { + # $1 decision, $2 reason. JSON on stdout, then done. # Reasons must stay plain ASCII without quotes/backslashes. printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse",' - printf '"permissionDecision":"deny",' - printf '"permissionDecisionReason":"heinzel guard: %s ' "$1" - printf '(CLAUDE.md - Critical Safety Rules). Blocked in all ' - printf 'permission modes. Explain this to the user; do not ' - printf 'rephrase the command to evade the guard."}}\n' + printf '"permissionDecision":"%s",' "$1" + printf '"permissionDecisionReason":"%s"}}\n' "$2" exit 0 } +deny() { + # A taboo: blocks in all permission modes. + decide deny "heinzel guard: $1 (CLAUDE.md - Critical Safety \ +Rules). Blocked in all permission modes. Explain this to the \ +user; do not rephrase the command to evade the guard." +} + # The model must not disarm the guard from inside a command. # Matches the assignment form only — merely mentioning the # variable name (docs, grep) is fine. Note: heredocs flow @@ -378,11 +396,43 @@ if hit 'sysrq-trigger'; then deny "sysrq-trigger powers off or resets the server without \ shutting anything down cleanly" fi +# Options a guest manager takes before its verb. +GOPTS='([[:space:]]+(-c|--connect|--project)[[:space:]]+[^[:space:]]+|[[:space:]]+-[^[:space:]]+)*' +# The shutdown verb of a guest manager (pct, qm, virsh) shuts a +# guest down cleanly and is asked like its stop (below), so the +# host rule reads the command with those spelled guest-shutdown. +# Any other shutdown stays in place and is caught. If the rewrite +# fails, the original command is checked: more blocking, not less. +HOST_CMD=$CMD +case $CMD in + *shutdown*) + GUEST_OFF=$(printf '%s\n' "$CMD" | sed -E \ + "s/((^|[^[:alnum:]_.-])(pct|qm|virsh)$GOPTS[[:space:]]+)shutdown/\1guest-shutdown/g") \ + && [ -n "$GUEST_OFF" ] && CMD=$GUEST_OFF + ;; +esac if hit_without '(^|[^[:alnum:]_-])shutdown([^[:alnum:]_-]|$)' \ '(^|[[:space:]])-(r|c)([[:space:]]|$)'; then deny "shutdown without -r powers off the server (reboots \ use shutdown -r; -c cancels)" fi +CMD=$HOST_CMD +# A guest verb is ASKED, not denied (see the end of the file). +# Only the manager's own verb counts: a service stopped or a file +# deleted inside a guest through exec stays allowed, and so do +# snapshot, image, network and storage verbs, which carry a noun +# before theirs. The case prefilter spares every other command +# the pipelines below. +case $CMD in + *stop*|*shutdown*|*destroy*|*delete*|*undefine*) + if hit "(^|[^[:alnum:]_.-])((pct|qm)[[:space:]]+(stop|shutdown|destroy)|(virsh|incus|lxc)$GOPTS[[:space:]]+(stop|shutdown|destroy|delete|undefine)|lxc-destroy)([^[:alnum:]_-]|\$)" \ + || hit_without '(^|[^[:alnum:]_.-])lxc-stop([^[:alnum:]_.-]|$)' \ + '(^|[[:space:]])(-r|--reboot)([[:space:]]|$)' + then + GUEST_ASK=1 + fi + ;; +esac # --- Filesystem creation -------------------------------------- if hit '(^|[^[:alnum:]_.-])mkfs(\.[[:alnum:]]+)?([^[:alnum:]_.-]|$)' @@ -654,5 +704,51 @@ partition table points at" fi fi +# --- Guest stop and delete: ask, never silently allow ---------- +# Stopping a system container or VM powers that server off and +# deleting it destroys it, but managing guests on a host heinzel +# administers is legitimate work. So this is the +# hook's second tier: the user confirms the exact command in a +# prompt. Membership is narrow on purpose — an effect earns "ask" +# instead of "deny" only when it is routine admin work AND no +# user-tunable policy already covers it (service restarts have +# memory/service-policy.md, so they stay with the model). +# +# The prompt must reach a human. Claude Code documents "ask" as +# forcing one in auto mode; for bypassPermissions and dontAsk it +# documents nothing, so those deny. Measured with Claude Code +# 2.1.267: in `claude -p` an ask is refused whatever the mode, so +# an unattended run stops rather than hanging, and a session +# started with --permission-mode auto reports "default" here. +# The operator override is HEINZEL_GUARD_DISABLE, as for a taboo. +if [ -n "$GUEST_ASK" ]; then + # Only this branch needs the mode, so it is read here rather + # than on every Bash call. No jq means no mode, hence deny. + MODE="" + if command -v jq >/dev/null 2>&1; then + MODE=$(printf '%s' "$INPUT" \ + | jq -r '.permission_mode // empty' 2>/dev/null) || MODE="" + fi + case $MODE in + default|acceptEdits|plan|auto) + decide ask "heinzel guard: stopping or deleting a system \ +container or VM powers off or destroys that server. Check the \ +guest ID and the host before approving." + ;; + bypassPermissions|dontAsk|"") + decide deny "heinzel guard: stopping or deleting a system \ +container or VM needs a confirmation prompt, and this session \ +shows none. Not a taboo: run it in a session that asks, or let \ +the user run it. Do not rephrase the command." + ;; + *) + decide deny "heinzel guard: stopping or deleting a system \ +container or VM needs a confirmation prompt, and this hook does \ +not know this session's permission mode. Tell the user to update \ +guard-taboos.sh. Do not rephrase the command." + ;; + esac +fi + # No taboo matched: no decision, normal permission flow applies. exit 0 diff --git a/rules/scheduled-housekeeping.md b/rules/scheduled-housekeeping.md index 2b248be..0ccd237 100644 --- a/rules/scheduled-housekeeping.md +++ b/rules/scheduled-housekeeping.md @@ -60,6 +60,9 @@ nothing left to ask. mode a run that hits a question it cannot answer idles; the timeout ends it instead of letting it hang until the next run. +- Stopping or deleting a system container or VM is + refused in an unattended run: the taboo guard + asks a person for it, and nobody answers. ## Cron Environment From 424dc21220ef23eb9cbf374a5ab5963bd70549f5 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sat, 19 Sep 2026 20:54:21 +0200 Subject: [PATCH 2/2] Brace the guest option pattern for ShellCheck ShellCheck read $GOPTS followed by a bracket expression as an array index (SC1087) and failed the error-severity step. ${GOPTS} expands the same string. Co-Authored-By: Claude Opus 5 --- .claude/hooks/guard-taboos.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/guard-taboos.sh b/.claude/hooks/guard-taboos.sh index bcd9c8e..203d2d2 100755 --- a/.claude/hooks/guard-taboos.sh +++ b/.claude/hooks/guard-taboos.sh @@ -407,7 +407,7 @@ HOST_CMD=$CMD case $CMD in *shutdown*) GUEST_OFF=$(printf '%s\n' "$CMD" | sed -E \ - "s/((^|[^[:alnum:]_.-])(pct|qm|virsh)$GOPTS[[:space:]]+)shutdown/\1guest-shutdown/g") \ + "s/((^|[^[:alnum:]_.-])(pct|qm|virsh)${GOPTS}[[:space:]]+)shutdown/\1guest-shutdown/g") \ && [ -n "$GUEST_OFF" ] && CMD=$GUEST_OFF ;; esac @@ -425,7 +425,7 @@ CMD=$HOST_CMD # the pipelines below. case $CMD in *stop*|*shutdown*|*destroy*|*delete*|*undefine*) - if hit "(^|[^[:alnum:]_.-])((pct|qm)[[:space:]]+(stop|shutdown|destroy)|(virsh|incus|lxc)$GOPTS[[:space:]]+(stop|shutdown|destroy|delete|undefine)|lxc-destroy)([^[:alnum:]_-]|\$)" \ + if hit "(^|[^[:alnum:]_.-])((pct|qm)[[:space:]]+(stop|shutdown|destroy)|(virsh|incus|lxc)${GOPTS}[[:space:]]+(stop|shutdown|destroy|delete|undefine)|lxc-destroy)([^[:alnum:]_-]|\$)" \ || hit_without '(^|[^[:alnum:]_.-])lxc-stop([^[:alnum:]_.-]|$)' \ '(^|[[:space:]])(-r|--reboot)([[:space:]]|$)' then