fix(guard): end the home match at a shell word boundary, not at whitespace
ci/woodpecker/pr/ci Pipeline was successful

Rounds 8 and 9 of the same class, in the two halves of one line.

The path arm required the home token to be followed by `/`. That silently
made `$HOME` itself -- the exact target the rule names -- legal: `git worktree
add $HOME` cleared a guard whose message is "this checks a repository out
under $HOME". Reachability is not theoretical; the command succeeds against an
empty home directory. Trailing `/` was then admitted, and with it every
terminator that is not whitespace: `$HOME;`, `$HOME&&`, `$HOME|`, `$HOME&`
and end-of-string all cleared, 25 shapes in all.

The fix that did not happen is worth recording, because it was mine. The brief
for this round prescribed a closed continuation class, `([^A-Za-z0-9_.-]|$)`,
on the reasoning that terminator sets are open and continuation sets are
closed. That is true of some axes and false of this one: `+ @ , : = %` all
continue a FILENAME, so `$HOME+bak/wt` and five siblings like it would have
been refused -- a new over-block traded for a closed bypass, which is not a
trade. The implementer measured the six counterexamples and declined the brief
rather than pick between two acceptance conditions that cannot both hold. They
are now permanent fixtures; a rejected over-block that nothing pins comes back.

The axis that IS closed is word termination, and it is closed by specification
rather than by anyone's imagination: POSIX fixes the unquoted metacharacter set
at space, tab, newline, and | & ; ( ) < >. So the path normalizer marks those
as an internal word boundary, in the same state machine and by the same
mechanism as the existing literal-dollar and literal-tilde markers, which is
what lets a QUOTED or escaped metacharacter stay word content: `"$HOME;bak"`
is one word and must be allowed. A raw marker byte arriving in the input is
encoded first, so input cannot forge or suppress a boundary. The home token
must now be preceded by start, `=`, or a boundary, and followed by a boundary,
`/` for a descendant, or end.

Verified by oracle rather than against the brief -- `bash -c "printf '%s' WORD"`
performs expansion and quote removal without executing, so the expected verdict
comes from the shell instead of from the reading that has now been wrong once.
Fixtures 198 -> 230; the new ones are red at both prior heads (15 failing at
4b8eba95, 21 at 3d0a882a), so they measure the change rather than passing on it.

Known and deliberately not addressed here: a checkout target that never names
$HOME at all. A relative target resolves against the cwd, and every agent seat
on this host runs with a cwd under $HOME, so `git clone URL` with no target at
all lands in $HOME and is invisible to a rule that matches home spellings.
That is a different rule -- it needs the effective cwd, which `cd` inside the
command can move -- and it is filed separately rather than becoming round ten
in this file.
This commit is contained in:
Hermes Agent
2026-08-13 05:08:01 -05:00
parent 4b8eba95a3
commit 20d86e392b
2 changed files with 64 additions and 8 deletions
@@ -40,8 +40,41 @@ FIXTURES="$TMP/fixtures.tsv"
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'
# The target may be HOME itself. End-of-command and whitespace terminate the
# token just as a slash does; punctuation that can extend a path does not.
printf '2\t{"tool_input":{"command":"git clone x $HOME"}}\tthe unbraced variable may name HOME exactly\n'
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\""}}\tquotes do not change the exact HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x ${HOME}"}}\tthe braced variable may name HOME exactly\n'
printf '2\t{"tool_input":{"command":"git clone x ~"}}\ttilde may name HOME exactly\n'
printf '2\t{"tool_input":{"command":"git worktree add $HOME topic"}}\twhitespace terminates an exact HOME target before another argument\n'
# Unquoted POSIX metacharacters terminate the target word even without spaces.
printf '2\t{"tool_input":{"command":"git clone x $HOME;echo x"}}\tsemicolon terminates an exact HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"&& echo x"}}\tand-if terminates a quoted exact HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x ${HOME}| cat"}}\ta pipe terminates a braced exact HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x ~&"}}\tbackground operator terminates a tilde HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x $HOME</dev/null"}}\tinput redirection terminates the target word\n'
printf '2\t{"tool_input":{"command":"git clone x $HOME>out"}}\toutput redirection terminates the target word\n'
printf '2\t{"tool_input":{"command":"( git clone x $HOME)"}}\ta subshell close terminates the exact HOME target\n'
printf '2\t{"tool_input":{"command":"git clone x $HOME\\necho x"}}\ta literal newline terminates the target word\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.bak/wt"}}\ta dot continues the path token into a sibling name\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt"}}\tplus is ordinary sibling filename content\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt"}}\tat-sign is ordinary sibling filename content\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME,bak/wt"}}\tcomma is ordinary sibling filename content\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME:bak/wt"}}\tcolon is ordinary sibling filename content\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME=bak/wt"}}\tequals is ordinary sibling filename content\n'
printf '0\t{"tool_input":{"command":"git clone x ${HOME}+bak/wt"}}\tbraced HOME plus suffix is still a sibling\n'
printf '0\t{"tool_input":{"command":"git clone x /home/tester+bak/wt"}}\ta literal plus-suffixed home path is a sibling\n'
printf '0\t{"tool_input":{"command":"git clone x /home/tester@bak/wt"}}\ta literal at-suffixed home path is a sibling\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt;echo x"}}\ta later terminator does not turn a sibling into HOME\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt&& echo x"}}\tand-if after a sibling preserves the allow\n'
printf '0\t{"tool_input":{"command":"git clone x \\"$HOME;bak/wt\\""}}\ta quoted semicolon is filename content, not a boundary\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME\\\\;bak/wt"}}\tan escaped semicolon is filename content, not a boundary\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME\\u001b/wt"}}\ta raw internal-marker byte is encoded as filename content\n'
printf '0\t{"tool_input":{"command":"git clone x /home/tester.bak/wt"}}\ta literal sibling path is not beneath HOME\n'
printf '0\t{"tool_input":{"command":"git clone x /home/testerx/wt"}}\ta longer literal basename is not HOME\n'
printf '0\t{"tool_input":{"command":"git clone x ~root/wt"}}\tanother account tilde is not this account HOME\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.
@@ -485,6 +518,10 @@ home_case 'a usable HOME still allows a checkout onto a work filesystem' \
0 '/home/tester' 'git clone https://example.invalid/x /src/wt'
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'
home_case 'a usable HOME catches the exact literal path without a trailing slash' \
2 '/home/tester' 'git clone https://example.invalid/x /home/tester' 'checks a repository out under'
home_case 'quotes around the exact literal HOME path do not change the target' \
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"' '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' \
@@ -100,13 +100,23 @@ normalize_command_words() {
-v protect_path_literals="$protect_path_literals" '
BEGIN {
state = "outside"; out = ""; word_start = 1
word_boundary = sprintf("%c", 27)
literal_dollar = sprintf("%c", 28)
literal_tilde = sprintf("%c", 29)
}
{
if (NR > 1) { out = out "\n"; word_start = 1 }
if (NR > 1) {
if (protect_path_literals) out = out word_boundary
else out = out "\n"
word_start = 1
}
for (i = 1; i <= length($0); i++) {
c = substr($0, i, 1)
# A raw marker byte is ordinary word content. Encode it visibly so only
# this machine can manufacture an internal boundary marker.
if (protect_path_literals && c == word_boundary) {
out = out "\\x1b"; word_start = 0; continue
}
if (state == "single") {
if (c == "\047") {
state = "outside"
@@ -144,7 +154,10 @@ normalize_command_words() {
}
continue
}
if (c == "\\") {
if (protect_path_literals && c ~ /[[:space:]|&;()<>]/) {
out = out word_boundary
word_start = 1
} else if (c == "\\") {
if (i == length($0)) {
out = out c
} else {
@@ -173,12 +186,14 @@ normalize_command_words() {
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
# checkout check. Unquoted POSIX shell metacharacters become an internal word-
# boundary marker; quoted/escaped metacharacters remain content. Shell-literal
# dollar/tilde characters become separate 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)"
PATH_WORD_BOUNDARY=$'\033'
# 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
@@ -307,8 +322,12 @@ derives the path and never consults \$HOME at all:
EOF
exit 2
fi
# Any argument that resolves under $HOME and is not under a work filesystem.
if printf '%s' "$CMD_PATHS" | grep -Eq "(^|[[:space:]=])($home_re)/"; then
# Any argument that resolves to $HOME itself or beneath it. The path-mode
# normalizer marks every unquoted POSIX shell word boundary, so the home token
# must be preceded by a boundary/start/assignment and followed by a boundary,
# slash (a descendant), or end. Arbitrary filename bytes remain content; this
# avoids over-blocking sibling names such as $HOME+bak or $HOME.bak.
if printf '%s' "$CMD_PATHS" | grep -Eq "(^|=|$PATH_WORD_BOUNDARY)($home_re)($PATH_WORD_BOUNDARY|/|$)"; then
cat <<EOF
BLOCKED: this checks a repository out under \$HOME.