fix(guard): classify Git option grammar
ci/woodpecker/pr/ci Pipeline was successful

Replace finite clone/worktree boolean allowlists with the closed separate-value grammar, including Git's accepted long abbreviations and bundled short options. Keep placement operands distinct from HOME-valued sources, metadata, commit-ish values, and rule-generated --no-* flags while preserving separate-git-dir and later-command traps.

Canonicalize shell-known HOME spellings, dot aliases, and existing symlink parents before placement comparison. Expand the hermetic suite from 242 to 292 fixtures and document the requirements and review evidence.

Deliberate residuals: a future unclassified value-taking clone placement option can fail open, and a future worktree value option can shift the inferred path; defaulting it to flag grammar avoids present-day over-blocking of Git's non-enumerable boolean family. PreToolUse symlink canonicalization is non-atomic against replacement after inspection; architectural closure is tracked by #1199.
This commit is contained in:
Hermes Agent
2026-08-13 06:46:25 -05:00
parent 91cc37bcf6
commit c09392e0c4
4 changed files with 364 additions and 39 deletions
@@ -40,6 +40,12 @@ 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'
# Lexically equivalent absolute paths must be compared after shell-known HOME
# expansion and dot-segment normalization, without resolving filesystem links.
printf '2\t{"tool_input":{"command":"git clone x /var/../$HOME/wt"}}\tHOME expansion after parent traversal is normalized before comparison\n'
printf '2\t{"tool_input":{"command":"git worktree add /var/../${HOME}/wt"}}\tworktree placement also normalizes embedded HOME expansion\n'
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir=/var/../$HOME/gd x /src/wt"}}\tseparate Git state cannot hide behind parent traversal\n'
printf '0\t{"tool_input":{"command":"git clone x $HOME/../outside-home/wt"}}\ta parent segment that leaves HOME is not over-blocked\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'
@@ -100,6 +106,57 @@ FIXTURES="$TMP/fixtures.tsv"
printf '0\t{"tool_input":{"command":"git clone --template $HOME/t https://example.invalid/x /src/wt"}}\ta space-separated HOME template remains a source\n'
printf '2\t{"tool_input":{"command":"git clone 2>/dev/null https://example.invalid/x $HOME/wt"}}\ta redirection before clone arguments does not become the destination\n'
printf '2\t{"tool_input":{"command":"git clone --reference $HOME https://example.invalid/x $HOME/wt"}}\ta source option does not hide a later HOME destination\n'
# Round eleven: Git accepts boolean options as a rule-generated family,
# including --no-* negations. Each command below was checked with Git itself:
# `git clone <option> /nonexistent-src /nonexistent-dst` reaches the missing
# source instead of reporting an unknown option. The HOME word is the source,
# not the explicit /src destination, so Bash expansion is allowed here.
printf '0\t{"tool_input":{"command":"git clone --bare $HOME/source /src/wt"}}\tbare clone keeps its HOME source distinct from the safe destination\n'
printf '0\t{"tool_input":{"command":"git clone --mirror $HOME/source /src/wt"}}\tmirror is an accepted flag and does not consume the HOME source\n'
printf '0\t{"tool_input":{"command":"git clone --ipv4 $HOME/source /src/wt"}}\tipv4 is an accepted flag and does not consume the HOME source\n'
printf '0\t{"tool_input":{"command":"git clone --ipv6 $HOME/source /src/wt"}}\tipv6 is an accepted flag and does not consume the HOME source\n'
printf '0\t{"tool_input":{"command":"git clone --no-local $HOME/source /src/wt"}}\tgenerated no-local remains a flag rather than a placement option\n'
printf '0\t{"tool_input":{"command":"git clone --no-reject-shallow $HOME/source /src/wt"}}\tgenerated no-reject-shallow remains a flag rather than placement\n'
printf '0\t{"tool_input":{"command":"git clone -4 $HOME/source /src/wt"}}\tthe short IPv4 flag leaves the HOME word in source position\n'
printf '0\t{"tool_input":{"command":"git clone -6 $HOME/source /src/wt"}}\tthe short IPv6 flag leaves the HOME word in source position\n'
printf '0\t{"tool_input":{"command":"git clone --no-bare $HOME/source /src/wt"}}\tan unusual generated negation is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-sparse $HOME/source /src/wt"}}\tgenerated no-sparse is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-dissociate $HOME/source /src/wt"}}\tgenerated no-dissociate is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-shallow-submodules $HOME/source /src/wt"}}\ta long generated negation is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-quiet $HOME/source /src/wt"}}\tgenerated no-quiet is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-progress $HOME/source /src/wt"}}\tgenerated no-progress is accepted without enumeration\n'
printf '0\t{"tool_input":{"command":"git clone --no-recurse-submodules $HOME/source /src/wt"}}\tgenerated no-recurse-submodules is accepted without enumeration\n'
# Git also generates accepted long abbreviations and short-option bundles.
# The closed value-taking option grammar must consume their values correctly.
printf '0\t{"tool_input":{"command":"git clone --templ $HOME/t $HOME/source /src/wt"}}\tan accepted template abbreviation consumes metadata rather than the source\n'
printf '0\t{"tool_input":{"command":"git clone -qj 1 $HOME/source /src/wt"}}\ta short flag bundle ending in jobs consumes its separate value\n'
printf '0\t{"tool_input":{"command":"git clone -qb topic $HOME/source /src/wt"}}\ta short flag bundle ending in branch consumes its separate value\n'
printf '2\t{"tool_input":{"command":"git clone --separate-git-d=$HOME/gd https://example.invalid/x /src/wt"}}\tan accepted placement-option abbreviation remains blocked in attached form\n'
printf '2\t{"tool_input":{"command":"git clone --separate-git-d $HOME/gd https://example.invalid/x /src/wt"}}\tan accepted placement-option abbreviation remains blocked in separate form\n'
# Worktree boolean options have the same generated-negation grammar. The next
# positional is its real path, so safe paths allow and HOME paths still block.
printf '0\t{"tool_input":{"command":"git worktree add --no-force /src/wt"}}\tgenerated worktree no-force accepts a safe path\n'
printf '0\t{"tool_input":{"command":"git worktree add --no-detach /src/wt"}}\tgenerated worktree no-detach accepts a safe path\n'
printf '0\t{"tool_input":{"command":"git worktree add --no-lock /src/wt"}}\tgenerated worktree no-lock accepts a safe path\n'
printf '0\t{"tool_input":{"command":"git worktree add --no-guess-remote /src/wt"}}\ta long worktree negation accepts a safe path without enumeration\n'
printf '0\t{"tool_input":{"command":"git worktree add -d /src/wt"}}\tthe documented short detach flag accepts a safe path\n'
printf '0\t{"tool_input":{"command":"git worktree add -q /src/wt"}}\tthe documented short quiet flag accepts a safe path\n'
printf '0\t{"tool_input":{"command":"git worktree add --lock --rea $HOME/note /src/wt"}}\tan accepted reason abbreviation consumes metadata rather than the path\n'
printf '0\t{"tool_input":{"command":"git worktree add -fb $HOME/topic /src/wt"}}\ta short branch bundle consumes its HOME-valued branch before the safe path\n'
printf '2\t{"tool_input":{"command":"git worktree add -fb topic $HOME/wt"}}\ta short branch bundle does not hide the later HOME path\n'
# Upstream Git defines --orphan as a boolean flag; -b still carries the branch.
printf '0\t{"tool_input":{"command":"git worktree add --orphan /src/wt"}}\torphan mode accepts a safe path without consuming it as a value\n'
printf '2\t{"tool_input":{"command":"git worktree add --orphan $HOME/wt"}}\torphan mode does not hide its HOME path\n'
printf '0\t{"tool_input":{"command":"git worktree add --orphan -b $HOME/topic /src/wt"}}\torphan mode leaves HOME branch metadata to the branch option\n'
printf '2\t{"tool_input":{"command":"git worktree add --orphan -b topic $HOME/wt"}}\torphan mode plus a branch option preserves HOME path blocking\n'
# The optional second positional is commit-ish metadata, never placement.
# HOME expands here, but the explicit worktree path remains safely under /src.
printf '0\t{"tool_input":{"command":"git worktree add /src/wt $HOME/topic"}}\ta HOME-shaped commit-ish is not the worktree path\n'
printf '2\t{"tool_input":{"command":"git worktree add --no-force $HOME/wt"}}\ta generated worktree negation does not hide the HOME path\n'
printf '2\t{"tool_input":{"command":"git worktree add --no-guess-remote $HOME/wt"}}\ta long worktree negation preserves HOME placement blocking\n'
# Explicit placement options and later simple commands remain traps.
printf '2\t{"tool_input":{"command":"git clone --bare $HOME/source /src/wt && git clone x $HOME/wt"}}\ta boolean flag in one command does not hide a later HOME destination\n'
printf '2\t{"tool_input":{"command":"git worktree add --no-force /src/wt; git clone x $HOME/wt"}}\ta worktree flag before a boundary does not hide later HOME placement\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
# longer word, so these two were refused at every head before this commit.
@@ -539,8 +596,34 @@ 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 'a repeated leading slash is the same absolute HOME path' \
2 '/home/tester' 'git clone https://example.invalid/x //home/tester/wt' 'checks a repository out under'
home_case 'dot segments cannot disguise the literal HOME path' \
2 '/home/tester' 'git worktree add /var/../home/tester/./wt' 'checks a repository out under'
home_case 'parent traversal into HOME is normalized for separate Git state' \
2 '/home/tester' 'git clone --separate-git-dir=/home/other/../tester/gd x /src/wt' 'checks a repository out under'
home_case 'normalization still permits a literal HOME sibling' \
0 '/home/tester' 'git clone x /home/tester/../tester-sibling/wt'
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'
# Resolve the longest existing parent physically before appending a nonexistent
# destination. Lexical normalization alone cannot see a symlink into HOME, and
# it applies `..` in the wrong order when the preceding component is a symlink.
SYMLINK_HOME="$TMP/symlink-home"
SYMLINK_SAFE="$TMP/symlink-safe"
mkdir -p "$SYMLINK_HOME/nested" "$SYMLINK_SAFE"
ln -s "$SYMLINK_HOME" "$TMP/home-link"
ln -s "$SYMLINK_HOME/nested" "$TMP/home-nested-link"
ln -s "$SYMLINK_SAFE" "$TMP/safe-link"
home_case 'a clone path through a symlink into HOME is refused' \
2 "$SYMLINK_HOME" "git clone x $TMP/home-link/wt" 'checks a repository out under'
home_case 'a worktree path through a symlink into HOME is refused' \
2 "$SYMLINK_HOME" "git worktree add $TMP/home-link/wt" 'checks a repository out under'
home_case 'symlink resolution occurs before a following parent segment' \
2 "$SYMLINK_HOME" "git clone x $TMP/home-nested-link/../wt" 'checks a repository out under'
home_case 'a symlink to a physical path outside HOME remains allowed' \
0 "$SYMLINK_HOME" "git clone x $TMP/safe-link/wt"
# The fail-closed arm is scoped to checkouts. If it were not, a seat with no
# HOME would have every command it runs refused, which is how a guard gets
# disabled rather than fixed.
@@ -288,6 +288,104 @@ case "${HOME-}" in
/?*) HOME_DIR="$HOME"; home_known=1 ;;
esac
# Resolve shell-known HOME spellings without evaluating arbitrary expansions.
# Path mode already encoded quoted/escaped dollar and tilde markers, so only
# expansion-capable spellings reach these token replacements.
expand_known_home() {
local path="$1"
awk -v path="$path" -v home="$HOME_DIR" '
function replace_home_token(value, token, bounded, out, pos, rest, nextc) {
out = ""
while ((pos = index(value, token)) > 0) {
rest = substr(value, pos + length(token))
nextc = substr(rest, 1, 1)
if (!bounded || nextc == "" || nextc !~ /[[:alnum:]_]/) {
out = out substr(value, 1, pos - 1) home
value = rest
} else {
out = out substr(value, 1, pos + length(token) - 1)
value = rest
}
}
return out value
}
BEGIN {
path = replace_home_token(path, "${HOME}", 0)
path = replace_home_token(path, "$HOME", 1)
if (path == "~" || substr(path, 1, 2) == "~/") {
path = home substr(path, 2)
}
printf "%s", path
}
'
}
# Collapse repeated separators and dot segments after filesystem resolution.
lexically_normalize_absolute_path() {
local path="$1"
awk -v path="$path" '
BEGIN {
if (substr(path, 1, 1) != "/") {
printf "%s", path
exit
}
count = split(path, component, "/")
depth = 0
for (i = 1; i <= count; i++) {
if (component[i] == "" || component[i] == ".") continue
if (component[i] == "..") {
if (depth > 0) depth--
continue
}
normalized[++depth] = component[i]
}
printf "/"
for (i = 1; i <= depth; i++) {
if (i > 1) printf "/"
printf "%s", normalized[i]
}
}
'
}
# Resolve the longest existing directory prefix physically, then append and
# normalize the nonexistent suffix. Resolving before collapsing `..` matters:
# the kernel follows a symlink first, then applies the parent segment. This is a
# pre-execution check, so a concurrent symlink replacement remains an inherent
# TOCTOU residual; existing aliases are nevertheless adjudicated correctly.
canonicalize_placement_path() {
local expanded candidate suffix leaf physical
expanded="$(expand_known_home "$1")"
case "$expanded" in
/*) ;;
*) printf '%s' "$expanded"; return 0 ;;
esac
candidate="$expanded"
suffix=""
while [ "$candidate" != "/" ] && [ "${candidate%/}" != "$candidate" ]; do
candidate="${candidate%/}"
done
while [ ! -d "$candidate" ]; do
[ "$candidate" = "/" ] && break
leaf="${candidate##*/}"
suffix="/$leaf$suffix"
candidate="${candidate%/*}"
[ -n "$candidate" ] || candidate="/"
done
physical="$(cd -P -- "$candidate" 2>/dev/null && pwd -P)" || return 1
lexically_normalize_absolute_path "$physical$suffix"
}
HOME_CANON=""
if [ "$home_known" -eq 1 ]; then
if ! HOME_CANON="$(canonicalize_placement_path "$HOME_DIR")"; then
HOME_CANON=""
home_known=0
fi
fi
W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
[ -x "$W/pr-review.sh" ] || W="$HOME_DIR/.config/mosaic/tools/git"
@@ -317,7 +415,7 @@ W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# 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')"
home_re="$home_re|$(printf '%s' "$HOME_CANON" | sed 's/[][\.*^$+?(){}|]/\\&/g')"
fi
# Emit only paths the checkout syntax can PLACE. The previous whole-command
@@ -330,58 +428,89 @@ fi
# splitting quoted whitespace; redirection markers let this scanner discard
# redirection operands rather than mistake them for clone destinations.
#
# Known Git options are classified by whether they consume a value. An unknown
# option with a separate following word is deliberately emitted as a possible
# placement as well as left available to positional parsing: Git can add a new
# path-taking option, and an unreadable option value must fail closed when it
# names HOME rather than silently becoming another bypass.
# Git options are classified by the small, closed grammar that consumes a
# SEPARATE value. Boolean flags are deliberately not listed: Git generates a
# `--no-` spelling for every boolean option, so that family is defined by a rule
# and cannot be completed by enumeration. Any option not in the separate-value
# grammar is one option token and leaves the next word in positional context.
#
# Git also accepts unique long-option abbreviations and bundled short options.
# Prefix matching models the former. For a short bundle, the first value-taking
# letter consumes the rest of that token; it consumes the next word only when it
# is the bundle's final letter.
#
# Deliberate residual: a future value-taking option absent from these closed
# lists defaults to flag grammar, so its following word remains positional. For
# clone, that can fail open if the future option itself places repository state.
# For worktree, it can shift which word is read as the path. Every value-taking
# and placement option Git supports today is classified (including accepted
# abbreviations of `--separate-git-dir`). Accepting this hypothetical future
# ambiguity avoids failing closed on Git's unbounded present-day boolean and
# generated-negation family.
checkout_placements() {
printf '%s' "$CMD_PATHS" | awk \
-v wb="$PATH_WORD_BOUNDARY" \
-v cb="$PATH_COMMAND_BOUNDARY" \
-v rb="$PATH_REDIRECTION" '
BEGIN { RS = cb; FS = wb }
BEGIN {
RS = cb; FS = wb
clone_value_count = split("--origin --branch --upload-pack --template --reference --reference-if-able --depth --shallow-since --shallow-exclude --filter --server-option --jobs --config --bundle-uri --revision --ref-format", clone_value_name, " ")
worktree_value_count = split("--reason", worktree_value_name, " ")
clone_placement_name = "--separate-git-dir"
}
function is_git(word) {
return word ~ /(^|\/)git$/
}
function clone_value_option(word) {
return word ~ /^(-[obujc]|--(origin|branch|upload-pack|template|reference|reference-if-able|depth|shallow-since|shallow-exclude|filter|server-option|jobs|config|bundle-uri|revision|ref-format))$/
function clone_separate_value_option(word, i, c) {
if (word ~ /^-[^-]/) {
for (i = 2; i <= length(word); i++) {
c = substr(word, i, 1)
if (c ~ /[obujc]/) return i == length(word)
}
return 0
}
if (word !~ /^--/ || word ~ /^--no-/ || index(word, "=") > 0) return 0
for (i = 1; i <= clone_value_count; i++) {
if (index(clone_value_name[i], word) == 1) return 1
}
return 0
}
function clone_flag_option(word) {
return word ~ /^--(local|no-hardlinks|shared|dissociate|quiet|verbose|progress|no-checkout|reject-shallow|no-tags|single-branch|no-single-branch|recurse-submodules|shallow-submodules|remote-submodules|sparse|also-filter-submodules)$/ || word ~ /^-[lqvns]$/
function clone_placement_option(word) {
return word ~ /^--/ && word !~ /^--no-/ && index(clone_placement_name, word) == 1
}
function worktree_value_option(word) {
return word ~ /^(-b|-B|--reason|--orphan)$/
function worktree_separate_value_option(word, i, c) {
if (word ~ /^-[^-]/) {
for (i = 2; i <= length(word); i++) {
c = substr(word, i, 1)
if (c ~ /[bB]/) return i == length(word)
}
return 0
}
if (word !~ /^--/ || word ~ /^--no-/ || index(word, "=") > 0) return 0
for (i = 1; i <= worktree_value_count; i++) {
if (index(worktree_value_name[i], word) == 1) return 1
}
return 0
}
function worktree_flag_option(word) {
return word ~ /^--(force|detach|checkout|no-checkout|lock|guess-remote|track|no-track)$/ || word == "-f"
}
function attached_known_value(word) {
return word ~ /^-[obujc].+/ || word ~ /^--(origin|branch|upload-pack|template|reference|reference-if-able|depth|shallow-since|shallow-exclude|filter|server-option|jobs|config|bundle-uri|revision|ref-format)=/ || word ~ /^(-b|-B).+/ || word ~ /^--(reason|orphan|track)=/
}
function emit_clone(start, count, i, j, word, options, positions, value) {
function emit_clone(start, count, i, j, word, options, positions, equals, value) {
delete positional
options = 1; positions = 0
for (i = start; i <= count; i++) {
word = token[i]
if (options && word == "--") { options = 0; continue }
if (options && word ~ /^--separate-git-dir=/) {
value = substr(word, index(word, "=") + 1)
equals = index(word, "=")
if (options && equals > 0 && clone_placement_option(substr(word, 1, equals - 1))) {
value = substr(word, equals + 1)
if (value != "") print value
continue
}
if (options && word == "--separate-git-dir") {
if (options && clone_placement_option(word)) {
if (i < count) print token[++i]
continue
}
if (options && clone_value_option(word)) { i++; continue }
if (options && (clone_flag_option(word) || attached_known_value(word))) continue
if (options && word ~ /^-/) {
# Unknown separate option value: fail closed if it is HOME-shaped.
if (word !~ /=/ && i < count) print token[i + 1]
continue
}
if (options && clone_separate_value_option(word)) { i++; continue }
if (options && word ~ /^-/) continue
positional[++positions] = word
}
# clone positional 1 is the source; every later positional can only be an
@@ -393,13 +522,10 @@ checkout_placements() {
for (i = start; i <= count; i++) {
word = token[i]
if (options && word == "--") { options = 0; continue }
if (options && worktree_value_option(word)) { i++; continue }
if (options && (worktree_flag_option(word) || attached_known_value(word))) continue
if (options && word ~ /^-/) {
# Same forward-compatible fail-closed rule as clone.
if (word !~ /=/ && i < count) print token[i + 1]
continue
}
if (options && worktree_separate_value_option(word)) { i++; continue }
if (options && word ~ /^-/) continue
# Only the first positional is placement; the optional second one is
# commit-ish metadata and must not impersonate the worktree path.
print word
return
}
@@ -456,7 +582,11 @@ EOF
placement_blocked=0
while IFS= read -r placement; do
[ -n "$placement" ] || continue
if printf '%s' "$placement" | grep -Eq "^($home_re)(/|$)"; then
if ! normalized_placement="$(canonicalize_placement_path "$placement")"; then
placement_blocked=1
break
fi
if printf '%s' "$normalized_placement" | grep -Eq "^($home_re)(/|$)"; then
placement_blocked=1
break
fi