Skip to content
Merged
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
66 changes: 66 additions & 0 deletions .claude/hooks/guard-taboos-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 \
Expand Down
47 changes: 36 additions & 11 deletions .claude/hooks/guard-taboos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -412,40 +412,65 @@ 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
# 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) | 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 \
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.29.0
2.29.1
Loading