fix(guard): apply word normalization to the path arm, not just the name arm

The quote/escape handling added over rounds 1-6 was wired into the command
NAME reading only. The PATH reading one line below it still matched the raw
text, so every spelling the name arm had learned to see was invisible to the
checkout check: `"$HOME"/wt`, `${HOME}/wt`, `"${HOME}"/wt` and a quoted
literal home path all cleared a guard whose entire purpose is to refuse them.
An identifier is not one spelling, and this file has now proven that seven
times; the arms of the rule were audited one at a time, and the class survived
in the arm nobody looked at.

The two readings need the same quote and escape handling but differ in one
respect, so this is one state machine with two modes rather than a copy:
substitution flattening is correct for a name and wrong for a path, where an
expansion-capable `$HOME` must stay visible. In path mode a shell-LITERAL
dollar or tilde -- single-quoted, escaped, or a quoted tilde -- becomes an
internal nonmatching marker, so quote removal cannot manufacture a home
spelling the shell would never expand, and `"~/wt"` is no longer refused.

`home_re` treats the braces as the pair they are. `$HOME}` expands HOME and
appends a literal brace; `${HOME` is not an expansion at all. Admitting either
as `${HOME}` would invent a home path the shell never resolves.

Verified by oracle rather than by assertion: for each spelling, `printf` under
bash performs expansion and quote removal without executing, and the resulting
path decides the expected verdict. 24 spellings, 0 mismatched here; 6
mismatched at 3d0a882a, which is what makes this a fix and not a rewrite.
Fixtures 184 -> 198.
This commit is contained in:
Hermes Agent
2026-08-13 04:38:44 -05:00
parent 3d0a882a63
commit 4b8eba95a3
2 changed files with 80 additions and 19 deletions
@@ -35,6 +35,22 @@ FIXTURES="$TMP/fixtures.tsv"
printf '2\t{"tool_input":{"command":"g\\"it\\" clone https://example.invalid/x $HOME/wt"}}\ta double quote inside git does not hide a checkout\n' printf '2\t{"tool_input":{"command":"g\\"it\\" clone https://example.invalid/x $HOME/wt"}}\ta double quote inside git does not hide a checkout\n'
printf '2\t{"tool_input":{"command":"g'"'"'it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta single quote inside git does not hide a checkout\n' printf '2\t{"tool_input":{"command":"g'"'"'it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta single quote inside git does not hide a checkout\n'
printf '2\t{"tool_input":{"command":"g\\\\it clone https://example.invalid/x $HOME/wt"}}\tan unquoted escape inside git does not hide a checkout\n' printf '2\t{"tool_input":{"command":"g\\\\it clone https://example.invalid/x $HOME/wt"}}\tan unquoted escape inside git does not hide a checkout\n'
# Path words use the same quote/escape state machine as names, but preserve
# substitutions so HOME remains visible. Quotes do not split the path word.
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"/wt"}}\ta closing quote between HOME and slash does not hide the path\n'
printf '2\t{"tool_input":{"command":"git clone x ${HOME}/wt"}}\tthe braced HOME spelling is the same home path\n'
printf '2\t{"tool_input":{"command":"git clone x \\"${HOME}\\"/wt"}}\tbraced HOME may also end a quoted span before the slash\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME_BACKUP/wt"}}\ta longer HOME-prefixed variable is a different path\n'
printf '0\t{"tool_input":{"command":"git clone x $HOMEBREW/wt"}}\tHOMEBREW is not HOME either\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME}/wt"}}\ta closing brace without an opening brace is a literal suffix\n'
printf '0\t{"tool_input":{"command":"git clone x ${HOME/wt"}}\tan opening brace without a close is not a HOME expansion\n'
# Quote removal must not create an expansion the shell never performs.
printf '0\t{"tool_input":{"command":"git clone x '"'"'$HOME'"'"'/wt"}}\tsingle-quoted HOME is a literal directory name\n'
printf '0\t{"tool_input":{"command":"git clone x \\\\$HOME/wt"}}\tan escaped dollar makes HOME literal outside quotes\n'
printf '0\t{"tool_input":{"command":"git clone x \\"\\\\$HOME\\"/wt"}}\tan escaped dollar makes HOME literal inside double quotes\n'
printf '0\t{"tool_input":{"command":"git clone x \\"~/wt\\""}}\ttilde does not expand inside double quotes\n'
printf '0\t{"tool_input":{"command":"git clone x '"'"'~/wt'"'"'"}}\ttilde does not expand inside single quotes\n'
printf '0\t{"tool_input":{"command":"git clone x \\\\~/wt"}}\tan escaped tilde is literal too\n'
printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\n' printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\n'
# Routing this arm through the shared name site also repaired an over-block it # Routing this arm through the shared name site also repaired an over-block it
# had carried from the start: the old whole-command regex found `git` INSIDE a # had carried from the start: the old whole-command regex found `git` INSIDE a
@@ -438,10 +454,12 @@ home_case() {
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}" local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
local out got local out got
n=$((n + 1)) n=$((n + 1))
local payload
payload="$(jq -nc --arg command "$cmd" '{tool_input:{command:$command}}')"
if [ "$homeval" = "@unset" ]; then if [ "$homeval" = "@unset" ]; then
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env -u HOME "$GUARD" 2>&1)" out="$(printf '%s' "$payload" | env -u HOME "$GUARD" 2>&1)"
else else
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env HOME="$homeval" "$GUARD" 2>&1)" out="$(printf '%s' "$payload" | env HOME="$homeval" "$GUARD" 2>&1)"
fi fi
got=$? got=$?
if [ "$got" != "$want" ]; then if [ "$got" != "$want" ]; then
@@ -467,6 +485,8 @@ home_case 'a usable HOME still allows a checkout onto a work filesystem' \
0 '/home/tester' 'git clone https://example.invalid/x /src/wt' 0 '/home/tester' 'git clone https://example.invalid/x /src/wt'
home_case 'a usable HOME still catches the literal path' \ home_case 'a usable HOME still catches the literal path' \
2 '/home/tester' 'git clone https://example.invalid/x /home/tester/wt' 'checks a repository out under' 2 '/home/tester' 'git clone https://example.invalid/x /home/tester/wt' 'checks a repository out under'
home_case 'a quoted literal HOME segment remains contiguous with the suffix' \
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"/wt' 'checks a repository out under'
home_case 'and the unexpanded $HOME spelling, which needs no resolution at all' \ home_case 'and the unexpanded $HOME spelling, which needs no resolution at all' \
2 '/home/tester' 'git worktree add $HOME/wt topic' 'checks a repository out under' 2 '/home/tester' 'git worktree add $HOME/wt topic' 'checks a repository out under'
# The fail-closed arm is scoped to checkouts. If it were not, a seat with no # The fail-closed arm is scoped to checkouts. If it were not, a seat with no
@@ -93,16 +93,29 @@ CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
# these commands inside quotes on a Bash line is refused too. Applying that same # these commands inside quotes on a Bash line is refused too. Applying that same
# rule here keeps the file coherent — the alternative is a guard where the # rule here keeps the file coherent — the alternative is a guard where the
# payload arm treats quotes as text and the name arms treat them as armour. # payload arm treats quotes as text and the name arms treat them as armour.
normalize_command_names() { normalize_command_words() {
awk ' local flatten_substitutions="${1:-1}"
BEGIN { state = "outside"; out = "" } local protect_path_literals="${2:-0}"
awk -v flatten_substitutions="$flatten_substitutions" \
-v protect_path_literals="$protect_path_literals" '
BEGIN {
state = "outside"; out = ""; word_start = 1
literal_dollar = sprintf("%c", 28)
literal_tilde = sprintf("%c", 29)
}
{ {
if (NR > 1) out = out "\n" if (NR > 1) { out = out "\n"; word_start = 1 }
for (i = 1; i <= length($0); i++) { for (i = 1; i <= length($0); i++) {
c = substr($0, i, 1) c = substr($0, i, 1)
if (state == "single") { if (state == "single") {
if (c == "\047") state = "outside" if (c == "\047") {
state = "outside"
} else {
if (protect_path_literals && c == "$") out = out literal_dollar
else if (protect_path_literals && c == "~") out = out literal_tilde
else out = out c else out = out c
word_start = 0
}
continue continue
} }
if (state == "double") { if (state == "double") {
@@ -114,33 +127,58 @@ normalize_command_names() {
} else { } else {
nextc = substr($0, i + 1, 1) nextc = substr($0, i + 1, 1)
if (nextc == "$" || nextc == "`" || nextc == "\"" || nextc == "\\") { if (nextc == "$" || nextc == "`" || nextc == "\"" || nextc == "\\") {
out = out nextc if (protect_path_literals && nextc == "$") out = out literal_dollar
else out = out nextc
} else { } else {
out = out c nextc out = out c
if (protect_path_literals && nextc == "~") out = out literal_tilde
else out = out nextc
} }
word_start = 0
i++ i++
} }
} else if (c != "$" && c != "(" && c != ")" && c != "`") { } else if (!flatten_substitutions || (c != "$" && c != "(" && c != ")" && c != "`")) {
out = out c if (protect_path_literals && c == "~") out = out literal_tilde
else out = out c
word_start = 0
} }
continue continue
} }
if (c == "\\") { if (c == "\\") {
if (i == length($0)) out = out c if (i == length($0)) {
else { out = out substr($0, i + 1, 1); i++ } out = out c
} else {
nextc = substr($0, i + 1, 1)
if (protect_path_literals && nextc == "$") out = out literal_dollar
else if (protect_path_literals && nextc == "~") out = out literal_tilde
else out = out nextc
i++
}
word_start = 0
} else if (c == "\047") { } else if (c == "\047") {
state = "single" state = "single"
} else if (c == "\"") { } else if (c == "\"") {
state = "double" state = "double"
} else if (c != "$" && c != "(" && c != ")" && c != "`") { } else if (!flatten_substitutions || (c != "$" && c != "(" && c != ")" && c != "`")) {
out = out c if (protect_path_literals && c == "~" && !word_start) out = out literal_tilde
else out = out c
if (c ~ /[[:space:]|;&]/) word_start = 1
else word_start = 0
} }
} }
} }
END { printf "%s", out } END { printf "%s", out }
' '
} }
CMD_NAMES="$(printf '%s' "$CMD" | normalize_command_names)" CMD_NAMES="$(printf '%s' "$CMD" | normalize_command_words 1 0)"
# Paths need the same quote and escape handling, but not name-mode substitution
# flattening: an expansion-capable `$HOME` spelling must remain visible to the
# checkout check. Shell-literal dollar/tilde characters (single-quoted, escaped,
# or a quoted tilde) become internal nonmatching markers; otherwise quote removal
# would create a HOME spelling the shell never expands. A path assembled from a
# different variable or command substitution is absent from the literal text and
# remains outside a text guard's visibility.
CMD_PATHS="$(printf '%s' "$CMD" | normalize_command_words 0 1)"
# The one place the shape of a program NAME is written down. Every name consumer # The one place the shape of a program NAME is written down. Every name consumer
# below uses it, so the next fix to this class lands in a single location instead of # below uses it, so the next fix to this class lands in a single location instead of
@@ -243,7 +281,10 @@ W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# left unresolved BLOCKS rather than clears — a guard may not clear a question it # left unresolved BLOCKS rather than clears — a guard may not clear a question it
# was unable to ask. The blast radius of that fail-closed arm is exactly one # was unable to ask. The blast radius of that fail-closed arm is exactly one
# command shape (clone / worktree add), not the session. # command shape (clone / worktree add), not the session.
home_re='~|\$HOME' # Braces are a pair, not independently optional: `$HOME}` expands HOME and
# appends a literal `}`, while `${HOME` is not a valid expansion. Treating either
# as `${HOME}` would create a home path the shell never resolves.
home_re='~|\$HOME|\$\{HOME\}'
if [ "$home_known" -eq 1 ]; then if [ "$home_known" -eq 1 ]; then
home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')" home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')"
fi fi
@@ -267,7 +308,7 @@ EOF
exit 2 exit 2
fi fi
# Any argument that resolves under $HOME and is not under a work filesystem. # Any argument that resolves under $HOME and is not under a work filesystem.
if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])($home_re)/"; then if printf '%s' "$CMD_PATHS" | grep -Eq "(^|[[:space:]=])($home_re)/"; then
cat <<EOF cat <<EOF
BLOCKED: this checks a repository out under \$HOME. BLOCKED: this checks a repository out under \$HOME.