From 4adcae8b09a5b11c08fd798f9e49bd5b11950003 Mon Sep 17 00:00:00 2001 From: Stefan Wintermeyer Date: Sun, 11 Oct 2026 09:28:21 +0200 Subject: [PATCH 1/3] Extend the taboo guard to the file-edit tools The guard hooked Bash only, so in local mode Edit or Write could replace sshd_config or authorized_keys without any rule seeing it. Edit, Write and NotebookEdit now run the guard in a file mode that judges the target path alone, so memory files can still name every protected path. When jq cannot name the target the whole input is scanned, which over-blocks instead of failing open. The rule from PR #62 is rewrapped, names the full sshd_config path and adds sed -i, and the hook registration test no longer passes vacuously on quoted script paths. An AI agent wrote this text in my name. I know that is problematic. --- .claude/hooks/guard-taboos-test.sh | 117 ++++++++++++++++++++++++++++- .claude/hooks/guard-taboos.sh | 58 +++++++++++++- .claude/settings.json | 10 +++ README.md | 3 +- rules/server-memory.md | 12 +-- 5 files changed, 191 insertions(+), 9 deletions(-) diff --git a/.claude/hooks/guard-taboos-test.sh b/.claude/hooks/guard-taboos-test.sh index a2b06fd..114d3de 100755 --- a/.claude/hooks/guard-taboos-test.sh +++ b/.claude/hooks/guard-taboos-test.sh @@ -731,6 +731,121 @@ check pass 'ls | tee >(wc -l) > list.txt' # count quote depth. check deny 'echo "editor -> $(readlink -f /usr/bin/editor)"' +# --- file-edit tools (Edit, Write, NotebookEdit) --------------- +# These tools write a file without any command line, so no rule +# above ever sees them. In file mode the guard judges the TARGET +# path alone. The text being written is data: a memory file may +# name every taboo there is. +file_json() { + # $1 tool name, $2 path key, $3 path, $4 text being written. + jq -n --arg t "$1" --arg k "$2" --arg p "$3" --arg c "$4" \ + '{tool_name:$t,tool_input:{($k):$p,content:$c,new_string:$c}}' +} + +check_file() { + EXPECT=$1 + OUT=$(file_json "$2" "$3" "$4" "$5" \ + | env -u HEINZEL_GUARD_DISABLE sh "$HOOK" file) + if printf '%s' "$OUT" \ + | grep -q '"permissionDecision":"deny"'; then + GOT=deny + else + GOT=pass + fi + if [ "$GOT" = "$EXPECT" ]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL [$EXPECT, got $GOT]: $2 $4" + fi +} + +if command -v jq >/dev/null 2>&1; then + check_file deny Write file_path /etc/ssh/sshd_config 'Port 22' + check_file deny Edit file_path /etc/ssh/sshd_config.d/10-x.conf x + check_file deny Edit file_path /usr/local/etc/ssh/sshd_config x + check_file deny Write file_path /etc/sshd_extra x + check_file deny Edit file_path /root/.ssh/authorized_keys x + check_file deny Write file_path /home/alice/.ssh/id_ed25519 x + check_file deny Write file_path /home/alice/.ssh/id_ed25519.pub x + check_file deny Write file_path /etc/ssh/ssh_host_ed25519_key x + check_file deny Write file_path /conf/sshd/ssh_host_rsa_key x + check_file deny Write file_path /dev/sda x + check_file deny Write file_path /tmp/../dev/nvme0n1 x + check_file deny NotebookEdit notebook_path \ + /root/.ssh/authorized_keys x + # The use case from PR #62: memory that NAMES the protected + # paths and a few taboo commands is ordinary text. + check_file pass Edit file_path \ + /home/alice/heinzel/memory/servers/h/memory.md \ + 'sshd: /etc/ssh/sshd_config, keys in /root/.ssh/authorized_keys' + check_file pass Write file_path \ + /home/alice/heinzel/memory/servers/h/changelog.log \ + 'shutdown -h now was refused; mkfs.ext4 /dev/sda1 was not run' + # Same boundary as the Bash rules: the client config and + # known_hosts are not keys, ssh_config is not sshd_config. + check_file pass Edit file_path /home/alice/.ssh/config x + check_file pass Edit file_path /etc/ssh/ssh_config x + # A directory merely called dev is not the device tree. + check_file pass Write file_path /home/alice/dev/sdk/notes.md x + check_file pass Write file_path /home/alice/dev/data/x.md x + check_file pass Write file_path /dev/null x + + # Degraded: when jq cannot name the target, the whole input is + # scanned for a protected path. A note about one is then blocked + # too, a write to one must never pass. + SHIM3=$(mktemp -d) + printf '#!/bin/sh\nexit 2\n' > "$SHIM3/jq" + chmod +x "$SHIM3/jq" + degraded_file() { + EXPECT=$1 + OUT=$(printf '%s' "$2" \ + | env -u HEINZEL_GUARD_DISABLE PATH="$SHIM3:$PATH" \ + sh "$HOOK" file) + if printf '%s' "$OUT" \ + | grep -q '"permissionDecision":"deny"'; then + GOT=deny + else + GOT=pass + fi + if [ "$GOT" = "$EXPECT" ]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL [$EXPECT, got $GOT] without jq: $2" + fi + } + degraded_file deny \ + "$(file_json Write file_path /etc/ssh/sshd_config x)" + degraded_file deny \ + "$(file_json Write file_path /root/.ssh/authorized_keys x)" + degraded_file deny "$(file_json Write file_path /dev/sda x)" + degraded_file deny "$(file_json Edit file_path /tmp/memory.md \ + 'sshd: /etc/ssh/sshd_config')" + degraded_file pass "$(file_json Edit file_path /tmp/memory.md \ + 'Last connected: 2026-02-25')" + degraded_file pass \ + "$(file_json Write file_path /home/alice/dev/sdk/n.md x)" + rm -rf "$SHIM3" +else + echo "SKIP: file-edit tool checks need jq to build fixtures" +fi + +# Every file-edit tool must be registered, and in file mode: +# without the argument the guard would scan the written text as +# a command line. +for TOOL in Edit Write NotebookEdit; do + if grep -A8 "\"matcher\": *\"[A-Za-z|]*$TOOL[A-Za-z|]*\"" \ + "$CLAUDE_DIR/settings.json" \ + | grep -q 'guard-taboos\.sh\\" file"'; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: settings.json does not run the guard in file" \ + "mode for $TOOL" + fi +done + # --- no negated hit() may remain ------------------------------- # A rule of the form hit X && ! hit Y evaluates Y against the # whole command string, so any unrelated flag disarms it (issue @@ -750,7 +865,7 @@ fi # open (issue #2). Every hook command must resolve its # script via $CLAUDE_PROJECT_DIR. SETTINGS="$CLAUDE_DIR/settings.json" -if grep -o '"command": *"[^"]*\.sh' "$SETTINGS" \ +if grep '"command":.*\.sh' "$SETTINGS" \ | grep -v 'CLAUDE_PROJECT_DIR' >/dev/null; then FAIL=$((FAIL + 1)) echo "FAIL: settings.json registers a hook script without" \ diff --git a/.claude/hooks/guard-taboos.sh b/.claude/hooks/guard-taboos.sh index f1d7194..71841b3 100755 --- a/.claude/hooks/guard-taboos.sh +++ b/.claude/hooks/guard-taboos.sh @@ -1,5 +1,6 @@ #!/bin/sh -# guard-taboos.sh — PreToolUse hook (matcher: Bash). +# guard-taboos.sh — PreToolUse hook (matcher: Bash, and +# Edit|Write|NotebookEdit with the argument "file"). # # Mechanically enforces heinzel's absolute taboos from # CLAUDE.md → Critical Safety Rules, below the model layer: @@ -28,6 +29,8 @@ # - a redirect onto a $( ) lookup, or an arrow onto any # expansion, whose target nobody sees before it runs # (issue #51) +# - a file-edit tool pointed at sshd_config, a key file or a +# raw disk device (see the file-mode section) # # 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 @@ -383,6 +386,59 @@ deny() { exit 0 } +# --- File-edit tools (file mode) ------------------------------ +# Edit, Write and NotebookEdit write a file with no command line +# at all, so none of the rules below ever sees them: in local +# mode Write could replace /etc/ssh/sshd_config or authorized_keys +# unchallenged. settings.json registers those tools with the +# argument "file", and the guard then judges the TARGET path +# alone. The text being written is data and is not scanned, which +# is what lets a memory file name every protected path +# (rules/server-memory.md). +# +# Same set as the Bash rules: sshd's config, a key file, a raw +# disk device. ~/.ssh/config and known_hosts stay editable, as +# they are for tee and a redirect. +# +# The device pattern is anchored, unlike DEV: unanchored it would +# deny every file under a directory called dev (~/dev/sdk/x.md). +# The quote anchors it in the degraded scan below, where the path +# is still inside its JSON string. +# +# When jq cannot name the target, the whole input is scanned +# instead. That also blocks a note ABOUT a protected path, and it +# must: an unproven target is a protected one. +# +# Boundary, left open: a symlink to a protected file under another +# name, and any other tool that writes files (an MCP server). The +# set of writers cannot be closed from here. +if [ "${1:-}" = "file" ]; then + TARGET="" + if command -v jq >/dev/null 2>&1; then + TARGET=$(printf '%s' "$INPUT" | jq -r \ + '.tool_input | (.file_path // .notebook_path // empty)' \ + 2>/dev/null) || TARGET="" + fi + [ -n "$TARGET" ] || TARGET="$INPUT" + FILEDEV="(^|\"|/\\.\\.?)/+${DEV#/}" + target_hit() { + printf '%s\n' "$TARGET" | grep -Eq "$1" + } + if target_hit "$SSHD"; then + deny "modifying sshd_config or a file merged into it is \ +never allowed, with a file-edit tool as little as with a command" + fi + if target_hit "$KEYFILE"; then + deny "writing into an SSH key file replaces the keys there, \ +with a file-edit tool as much as with a command" + fi + if target_hit "$FILEDEV"; then + deny "writing onto a raw disk device overwrites its content \ +and partition table" + fi + exit 0 +fi + # 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 diff --git a/.claude/settings.json b/.claude/settings.json index 5b61ea1..c27ec52 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -40,6 +40,16 @@ "timeout": 5 } ] + }, + { + "matcher": "Edit|Write|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-taboos.sh\" file", + "timeout": 5 + } + ] } ] } diff --git a/README.md b/README.md index 98367cc..9fb662b 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,8 @@ The safety rules are part of every session. `--dangerously-skip-permissions`: halt and poweroff, `mkfs`, partition-table writers, deleting or overwriting SSH keys, writes to `sshd_config`. It also catches them - inside `ssh host "…"` or behind `python3 -c`. Read-only + inside `ssh host "…"`, behind `python3 -c` and when the + file-edit tools target a key or `sshd_config`. Read-only forms (`fdisk -l`) stay allowed. For a legitimate exception such as an OS replacement, start the session with `HEINZEL_GUARD_DISABLE=1`. OpenCode does not run this hook; diff --git a/rules/server-memory.md b/rules/server-memory.md index dde67c2..ab8922d 100644 --- a/rules/server-memory.md +++ b/rules/server-memory.md @@ -33,9 +33,9 @@ outdated entries, merge related items. Memory files never hold credential values — see `rules/secrets.md`. -Edit memory files with the agent's file-edit tool. -Never edit them through an interpreter (`python3 -`, -`perl -e`). Memory often names `sshd_config` and key -paths. -The taboo guard blocks an interpreter command that -contains them. +Edit memory files with the agent's file-edit tool, +never through `sed -i` or an interpreter +(`python3 -`, `perl -e`). Memory often names key +paths and `/etc/ssh/sshd_config`. The taboo guard +blocks a command line that carries them: it cannot +tell a note about a path from a write to it. From 7a7ba3680684a29c22b5884a6bc7e1c34ff2a1dd Mon Sep 17 00:00:00 2001 From: Stefan Wintermeyer Date: Sun, 11 Oct 2026 09:28:23 +0200 Subject: [PATCH 2/3] Bump to 2.29.0 Ships the memory-editing rule from #62 and the guard's new file mode. A minor release because the guard now denies tool calls it used to let through: Edit, Write and NotebookEdit onto sshd_config, an SSH key file or a raw disk device. An AI agent wrote this text in my name. I know that is problematic. --- CHANGELOG.md | 17 +++++++++++++++++ VERSION | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82cf217..a7d5050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 2.29.0 — 2026-10-11 + +- **The taboo guard covers the file-edit tools.** It + hooked `Bash` only, so in local mode `Edit` or + `Write` could replace `sshd_config` or + `authorized_keys` unchallenged. Both, and + `NotebookEdit`, are now denied when their target + is sshd's config, an SSH key file or a raw disk + device. Only the target path is judged, never the + text being written. +- **Memory files are edited with the file-edit + tool.** Server memory names key paths and + `/etc/ssh/sshd_config`, and the guard blocks + `sed -i` or an interpreter that carries them on + its command line. `rules/server-memory.md` now + says so. Contributed by Oliver Andrich (#62). + ## 2.28.1 — 2026-10-10 - **The README opens with a trailer and is less diff --git a/VERSION b/VERSION index 9738a24..f013568 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.28.1 +2.29.0 From 54ed0170de520b76f39d519ff705eef8bf33e407 Mon Sep 17 00:00:00 2001 From: Stefan Wintermeyer Date: Sun, 11 Oct 2026 09:34:58 +0200 Subject: [PATCH 3/3] Match a file-edit target under every spelling of its path The file mode compared the path as written, so a capitalized name on a case-insensitive filesystem, or a //, /./ or x/../ inside the path, reached sshd_config or a key past the guard. The target is now matched without regard to case, as given and with those forms folded away, and every path key in the input is judged. Without jq a JSON escape in the input is denied, because only a parser can show what it hides. An AI agent wrote this text in my name. I know that is problematic. --- .claude/hooks/guard-taboos-test.sh | 43 +++++++++++++++++++++++++++++- .claude/hooks/guard-taboos.sh | 31 ++++++++++++++++++--- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/.claude/hooks/guard-taboos-test.sh b/.claude/hooks/guard-taboos-test.sh index 114d3de..4ddee41 100755 --- a/.claude/hooks/guard-taboos-test.sh +++ b/.claude/hooks/guard-taboos-test.sh @@ -774,6 +774,36 @@ if command -v jq >/dev/null 2>&1; then check_file deny Write file_path /tmp/../dev/nvme0n1 x check_file deny NotebookEdit notebook_path \ /root/.ssh/authorized_keys x + # Another spelling of the same file. A filesystem that ignores + # case (macOS) opens it under any capitalization, and the kernel + # resolves //, /./ and x/../ before any pattern sees them. + check_file deny Write file_path /etc/ssh/SSHD_CONFIG x + check_file deny Write file_path /Users/a/.SSH/Authorized_Keys x + check_file deny Write file_path /Users/a/.ssh/ID_ed25519 x + check_file deny Write file_path /etc/ssh/./sshd_config x + check_file deny Write file_path /etc//ssh/sshd_config x + check_file deny Write file_path /etc/ssh/x/../sshd_config x + check_file deny Write file_path /etc/ssh/x/y/../../sshd_config x + check_file deny Write file_path /etc/ssh/sshd_config.d/./a.conf x + check_file deny Write file_path /home/a/.ssh/./id_ed25519 x + check_file deny Write file_path /home/a/.ssh//id_ed25519 x + check_file deny Write file_path /home/a/.ssh/x/../id_ed25519 x + check_file deny Write file_path /etc/ssh/./ssh_host_rsa_key x + check_file deny Write file_path /dev/./sda x + check_file deny Write file_path /dev//sda x + check_file deny Write file_path /dev/x/../sda x + # Both path keys at once: every one of them is judged, so a + # harmless one cannot stand in front of a protected one. + OUT=$(jq -n '{tool_name:"NotebookEdit",tool_input:{file_path: + "/tmp/a.ipynb",notebook_path:"/etc/ssh/sshd_config"}}' \ + | env -u HEINZEL_GUARD_DISABLE sh "$HOOK" file) + if printf '%s' "$OUT" \ + | grep -q '"permissionDecision":"deny"'; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: a second path key hid a protected target" + fi # The use case from PR #62: memory that NAMES the protected # paths and a few taboo commands is ordinary text. check_file pass Edit file_path \ @@ -822,6 +852,17 @@ if command -v jq >/dev/null 2>&1; then degraded_file deny "$(file_json Write file_path /dev/sda x)" degraded_file deny "$(file_json Edit file_path /tmp/memory.md \ 'sshd: /etc/ssh/sshd_config')" + # JSON may spell a slash or any character as an escape. Only a + # JSON parser can undo that, so without one it is unprovable. + degraded_file deny \ + '{"tool_input":{"file_path":"\/etc\/ssh\/sshd_config"}}' + # The backslash is built at run time: an editor that decodes + # escapes on save would turn the fixture into the plain path. + BSL=$(printf '\134') + degraded_file deny \ + '{"tool_input":{"file_path":"/tmp/sshd_conf'"$BSL"'u0069g"}}' + degraded_file deny \ + "$(file_json Write file_path /etc/ssh/./SSHD_config x)" degraded_file pass "$(file_json Edit file_path /tmp/memory.md \ 'Last connected: 2026-02-25')" degraded_file pass \ @@ -835,7 +876,7 @@ fi # without the argument the guard would scan the written text as # a command line. for TOOL in Edit Write NotebookEdit; do - if grep -A8 "\"matcher\": *\"[A-Za-z|]*$TOOL[A-Za-z|]*\"" \ + if grep -A8 "\"matcher\": *\"[A-Za-z|]*${TOOL}[A-Za-z|]*\"" \ "$CLAUDE_DIR/settings.json" \ | grep -q 'guard-taboos\.sh\\" file"'; then PASS=$((PASS + 1)) diff --git a/.claude/hooks/guard-taboos.sh b/.claude/hooks/guard-taboos.sh index 71841b3..25a78b6 100755 --- a/.claude/hooks/guard-taboos.sh +++ b/.claude/hooks/guard-taboos.sh @@ -405,9 +405,18 @@ deny() { # The quote anchors it in the degraded scan below, where the path # is still inside its JSON string. # +# A path has more than one spelling, and the kernel resolves all +# of them before the write: //, /./ and x/../ anywhere in it, and +# on a filesystem that ignores case (macOS) any capitalization. +# So the target is matched without regard to case, and both as +# given and with those three forms folded away. Every path key in +# the input is judged, not the first one found. +# # When jq cannot name the target, the whole input is scanned # instead. That also blocks a note ABOUT a protected path, and it -# must: an unproven target is a protected one. +# must: an unproven target is a protected one. JSON can spell a +# slash or any character as a backslash escape, which only a JSON +# parser undoes, so such an escape is denied there as well. # # Boundary, left open: a symlink to a protected file under another # name, and any other tool that writes files (an MCP server). The @@ -416,13 +425,27 @@ if [ "${1:-}" = "file" ]; then TARGET="" if command -v jq >/dev/null 2>&1; then TARGET=$(printf '%s' "$INPUT" | jq -r \ - '.tool_input | (.file_path // .notebook_path // empty)' \ + '.tool_input | (.file_path, .notebook_path) | select(. != null)' \ 2>/dev/null) || TARGET="" fi - [ -n "$TARGET" ] || TARGET="$INPUT" + if [ -z "$TARGET" ]; then + TARGET="$INPUT" + if printf '%s\n' "$TARGET" \ + | grep -Eq '\\(/|u[0-9a-fA-F]{4})'; then + deny "the target of this file edit could not be read: jq is \ +missing or failed, and the path may be hidden behind a JSON escape" + fi + fi + FOLDED=$(printf '%s\n' "$TARGET" | sed -E -e 's#/+#/#g' \ + -e ':a' -e 's#/\./#/#g' -e 'ta' \ + -e ':b' -e 's#/[^/]+/\.\./#/#' -e 'tb' 2>/dev/null) || FOLDED="" + if [ -z "$FOLDED" ]; then + deny "the target path of this file edit could not be \ +normalised, so it cannot be shown to be an ordinary file" + fi FILEDEV="(^|\"|/\\.\\.?)/+${DEV#/}" target_hit() { - printf '%s\n' "$TARGET" | grep -Eq "$1" + printf '%s\n%s\n' "$TARGET" "$FOLDED" | grep -Eiq "$1" } if target_hit "$SSHD"; then deny "modifying sshd_config or a file merged into it is \