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:
@@ -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 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'
|
||||
# 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'
|
||||
# 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
|
||||
@@ -438,10 +454,12 @@ home_case() {
|
||||
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
|
||||
local out got
|
||||
n=$((n + 1))
|
||||
local payload
|
||||
payload="$(jq -nc --arg command "$cmd" '{tool_input:{command:$command}}')"
|
||||
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
|
||||
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env HOME="$homeval" "$GUARD" 2>&1)"
|
||||
out="$(printf '%s' "$payload" | env HOME="$homeval" "$GUARD" 2>&1)"
|
||||
fi
|
||||
got=$?
|
||||
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'
|
||||
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 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' \
|
||||
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
|
||||
|
||||
@@ -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
|
||||
# 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.
|
||||
normalize_command_names() {
|
||||
awk '
|
||||
BEGIN { state = "outside"; out = "" }
|
||||
normalize_command_words() {
|
||||
local flatten_substitutions="${1:-1}"
|
||||
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++) {
|
||||
c = substr($0, i, 1)
|
||||
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
|
||||
word_start = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (state == "double") {
|
||||
@@ -114,33 +127,58 @@ normalize_command_names() {
|
||||
} else {
|
||||
nextc = substr($0, i + 1, 1)
|
||||
if (nextc == "$" || nextc == "`" || nextc == "\"" || nextc == "\\") {
|
||||
out = out nextc
|
||||
if (protect_path_literals && nextc == "$") out = out literal_dollar
|
||||
else out = out nextc
|
||||
} 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++
|
||||
}
|
||||
} else if (c != "$" && c != "(" && c != ")" && c != "`") {
|
||||
out = out c
|
||||
} else if (!flatten_substitutions || (c != "$" && c != "(" && c != ")" && c != "`")) {
|
||||
if (protect_path_literals && c == "~") out = out literal_tilde
|
||||
else out = out c
|
||||
word_start = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (c == "\\") {
|
||||
if (i == length($0)) out = out c
|
||||
else { out = out substr($0, i + 1, 1); i++ }
|
||||
if (i == length($0)) {
|
||||
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") {
|
||||
state = "single"
|
||||
} else if (c == "\"") {
|
||||
state = "double"
|
||||
} else if (c != "$" && c != "(" && c != ")" && c != "`") {
|
||||
out = out c
|
||||
} else if (!flatten_substitutions || (c != "$" && c != "(" && c != ")" && 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 }
|
||||
'
|
||||
}
|
||||
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
|
||||
# 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
|
||||
# was unable to ask. The blast radius of that fail-closed arm is exactly one
|
||||
# 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
|
||||
home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')"
|
||||
fi
|
||||
@@ -267,7 +308,7 @@ EOF
|
||||
exit 2
|
||||
fi
|
||||
# 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
|
||||
BLOCKED: this checks a repository out under \$HOME.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user