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
158 changes: 157 additions & 1 deletion .claude/hooks/guard-taboos-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,162 @@ 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
# 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 \
/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')"
# 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 \
"$(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
Expand All @@ -750,7 +906,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" \
Expand Down
81 changes: 80 additions & 1 deletion .claude/hooks/guard-taboos.sh
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -383,6 +386,82 @@ 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.
#
# 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. 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
# 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)' \
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
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%s\n' "$TARGET" "$FOLDED" | grep -Eiq "$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
Expand Down
10 changes: 10 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
]
}
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.28.1
2.29.0
12 changes: 6 additions & 6 deletions rules/server-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading