Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions .claude/hooks/guard-taboos-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,40 @@ 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
if [ "$GOT" = "$EXPECT" ]; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
echo "FAIL [$EXPECT, got $GOT]: $CMDSTR"
echo "FAIL [$EXPECT, got $GOT${3:+, mode $3}]: $CMDSTR"
fi
}

Expand Down Expand Up @@ -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
Expand Down
114 changes: 105 additions & 9 deletions .claude/hooks/guard-taboos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:]_.-]|$)'
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions rules/scheduled-housekeeping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading