fix(guard): end the home match at a shell word boundary, not at whitespace
ci/woodpecker/pr/ci Pipeline was successful
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 at4b8eba95, 21 at3d0a882a), 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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user