From ec9cec8de5aceeac10d98c0b349e1bc478b95d5c Mon Sep 17 00:00:00 2001 From: Stefan Wintermeyer Date: Sun, 11 Oct 2026 09:41:03 +0200 Subject: [PATCH 1/2] Resolve relative and Unicode-folded targets in the guard's file mode A review of 2.29.0 found two spellings the file mode missed. A relative target was matched as written, so a bare sshd_config from inside /etc/ssh passed; it is now resolved against the cwd the hook input carries. APFS folds the long s and the Kelvin sign onto ASCII letters and HFS+ ignores zero-width characters, so the target is also matched with those mapped and all other non-ASCII bytes removed. Without jq a relative target is denied, because nothing can resolve it. An AI agent wrote this text in my name. I know that is problematic. --- .claude/hooks/guard-taboos-test.sh | 66 ++++++++++++++++++++++++++++++ .claude/hooks/guard-taboos.sh | 47 ++++++++++++++++----- 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/.claude/hooks/guard-taboos-test.sh b/.claude/hooks/guard-taboos-test.sh index 4ddee41..c1ffcbd 100755 --- a/.claude/hooks/guard-taboos-test.sh +++ b/.claude/hooks/guard-taboos-test.sh @@ -792,6 +792,66 @@ if command -v jq >/dev/null 2>&1; then 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 + # Case folding is not ASCII-only: APFS folds the long s onto s + # and the Kelvin sign onto k, and HFS+ ignores a zero-width + # joiner altogether. Bytes are built here so that no editor + # normalizes them away. + LONGS=$(printf '\305\277') + KELVIN=$(printf '\342\204\252') + ZWJ=$(printf '\342\200\215') + UUML=$(printf '\303\274') + check_file deny Write file_path "/etc/ssh/${LONGS}shd_config" x + check_file deny Write file_path \ + "/Users/a/.ssh/authorized_${KELVIN}eys" x + check_file deny Write file_path "/etc/ssh/sshd_${ZWJ}config" x + check_file deny Write file_path \ + "/etc/ssh/.${ZWJ}/${LONGS}shd_config" x + check_file pass Write file_path \ + "/home/alice/Notizen/${UUML}bersicht.md" x + # A relative target is resolved against the session's cwd, so it + # is judged there: the bare name sshd_config is a protected file + # in /etc/ssh and an ordinary one anywhere else. + rel_file() { + EXPECT=$1 + OUT=$(jq -n --arg c "$2" --arg p "$3" \ + '{cwd:$c,tool_name:"Write",tool_input:{file_path:$p}}' \ + | 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]: relative $3 in $2" + fi + } + rel_file deny /etc/ssh sshd_config + rel_file deny /etc sshd_extra + rel_file deny /etc/ssh ssh_host_ed25519_key + rel_file deny /home/alice/.ssh id_ed25519 + rel_file deny /home/alice .ssh/id_ed25519 + rel_file deny /etc/nginx ../ssh/sshd_config + rel_file deny /dev sda + rel_file pass /home/alice/heinzel memory/servers/h/memory.md + rel_file pass /home/alice/heinzel sshd_config + # No cwd in the input: the hook's own working directory counts. + RELDIR=$(mktemp -d) + mkdir "$RELDIR/.ssh" + OUT=$(jq -n '{tool_input:{file_path:"id_ed25519"}}' \ + | (cd "$RELDIR/.ssh" \ + && 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: relative key name passed without a cwd field" + fi + rm -rf "$RELDIR" # 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: @@ -863,6 +923,12 @@ if command -v jq >/dev/null 2>&1; then '{"tool_input":{"file_path":"/tmp/sshd_conf'"$BSL"'u0069g"}}' degraded_file deny \ "$(file_json Write file_path /etc/ssh/./SSHD_config x)" + # A relative target cannot be resolved without a JSON parser. + degraded_file deny '{"tool_input":{"file_path":"sshd_config"}}' + degraded_file deny \ + "$(file_json NotebookEdit notebook_path id_ed25519 x)" + degraded_file deny \ + "$(file_json Write file_path "/etc/ssh/${LONGS}shd_config" x)" degraded_file pass "$(file_json Edit file_path /tmp/memory.md \ 'Last connected: 2026-02-25')" degraded_file pass \ diff --git a/.claude/hooks/guard-taboos.sh b/.claude/hooks/guard-taboos.sh index 25a78b6..feb75ad 100755 --- a/.claude/hooks/guard-taboos.sh +++ b/.claude/hooks/guard-taboos.sh @@ -412,11 +412,23 @@ deny() { # given and with those three forms folded away. Every path key in # the input is judged, not the first one found. # +# Case folding is not ASCII-only. APFS folds the long s (U+017F) +# onto s and the Kelvin sign (U+212A) onto k, the only two +# characters Unicode folds onto an ASCII letter, and HFS+ skips +# zero-width characters when it compares names. Every protected +# name is plain ASCII, so the target is also matched with those +# two mapped and with every other non-ASCII byte removed. +# +# A relative target is resolved against the session's cwd, which +# the hook input carries: the bare name sshd_config is the +# protected file in /etc/ssh and an ordinary one anywhere else. +# # 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. 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. +# parser undoes, so such an escape is denied there as well, and +# so is a relative target, which cannot be resolved. # # Boundary, left open: a symlink to a protected file under another # name, and any other tool that writes files (an MCP server). The @@ -424,28 +436,41 @@ deny() { 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) | select(. != null)' \ + TARGET=$(printf '%s' "$INPUT" | jq -r --arg pwd "$PWD" ' + ((.cwd // $pwd) | tostring) as $c + | .tool_input | (.file_path, .notebook_path) + | select(. != null) | tostring + | if startswith("/") then . else $c + "/" + . end' \ 2>/dev/null) || TARGET="" fi if [ -z "$TARGET" ]; then TARGET="$INPUT" - if printf '%s\n' "$TARGET" \ - | grep -Eq '\\(/|u[0-9a-fA-F]{4})'; then + if printf '%s\n' "$TARGET" | LC_ALL=C grep -Eq \ + '\\(/|u[0-9a-fA-F]{4})|"(file|notebook)_path"[[:space:]]*:[[:space:]]*"[^/]' + 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" +missing or failed, and the path is relative or 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 + LONGS=$(printf '\305\277') + KELVIN=$(printf '\342\204\252') + MAPPED=$(printf '%s\n' "$TARGET" | LC_ALL=C sed \ + -e "s/$LONGS/s/g" -e "s/$KELVIN/k/g" 2>/dev/null) || MAPPED="" + ASCII=$(printf '%s\n' "$MAPPED" \ + | LC_ALL=C tr -d '\200-\377' 2>/dev/null) || ASCII="" + FOLDED=$(printf '%s\n%s\n' "$MAPPED" "$ASCII" \ + | LC_ALL=C sed -E -e 's#/+#/#g' \ + -e ':a' -e 's#/\./#/#g' -e 'ta' \ + -e ':b' -e 's#/[^/]+/\.\./#/#' -e 'tb' 2>/dev/null) || FOLDED="" + if [ -z "$MAPPED" ] || [ -z "$ASCII" ] || [ -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%s\n' "$TARGET" "$FOLDED" | grep -Eiq "$1" + printf '%s\n%s\n%s\n%s\n' "$TARGET" "$MAPPED" "$ASCII" "$FOLDED" \ + | LC_ALL=C grep -Eiq "$1" } if target_hit "$SSHD"; then deny "modifying sshd_config or a file merged into it is \ From 5ca161a65c455ac25e50071fc72e0f7511d542b5 Mon Sep 17 00:00:00 2001 From: Stefan Wintermeyer Date: Sun, 11 Oct 2026 09:41:05 +0200 Subject: [PATCH 2/2] Bump to 2.29.1 Ships two fixes to the guard's file mode from #64. A patch release because the mode keeps its meaning: it denies the same three targets, now also under a relative path and under a Unicode spelling that macOS folds onto the protected name. An AI agent wrote this text in my name. I know that is problematic. --- CHANGELOG.md | 10 ++++++++++ VERSION | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d5050..bf08cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2.29.1 — 2026-10-11 + +- **The guard's file mode knows two more spellings + of a path.** A relative target is resolved against + the session's working directory, so a bare + `sshd_config` written from inside `/etc/ssh` is + denied. On macOS the long s and the Kelvin sign + fold onto `s` and `k`, and such a name no longer + passes as a different file (#64). + ## 2.29.0 — 2026-10-11 - **The taboo guard covers the file-edit tools.** It diff --git a/VERSION b/VERSION index f013568..6988617 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.29.0 +2.29.1