diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index b4304669..d0a3998f 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -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 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index ac12b449..9cd9d10e 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -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" - else out = out c + 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 <