fix(guard): inspect checkout placement operands
ci/woodpecker/pr/ci Pipeline was successful

Classify git clone and worktree add operands so HOME-valued environment assignments, sources, references, templates, and metadata do not impersonate checkout destinations. Preserve both forms of clone --separate-git-dir as real placement targets and distinguish shell words, command boundaries, and redirections in the existing quote-aware normalized stream.

Deliberate fail-closed residual: unknown future Git options with a separate following word are not adjudicated as source-only. Their value remains a possible placement, so a HOME-shaped value blocks rather than silently creating a bypass. Relative destinations whose effective path depends on cwd remain out of scope in #1197.
This commit is contained in:
Hermes Agent
2026-08-13 05:55:33 -05:00
parent 20d86e392b
commit 91cc37bcf6
3 changed files with 187 additions and 8 deletions
@@ -0,0 +1,30 @@
# #1174 — Wrapper guard round 10
## Objective
Make checkout enforcement judge Git placement operands rather than every HOME-shaped word in the command, without reopening `--separate-git-dir` placement under HOME.
## Plan
1. Reproduce the four over-blocks and the placement-option control at head `20d86e39`.
2. Add RED fixtures before production changes.
3. Extract clone/worktree placement operands from the existing shell-aware normalized stream.
4. Run the full guard corpus, historical-head discrimination, syntax/static checks, probes, review, and CI.
## Progress and evidence
- Reproduced: `NOTE=$HOME`, `--reference=$HOME`, `GIT_DIR=$HOME/x`, and `--template=$HOME/t` all blocked despite explicit `/src/wt` destinations.
- RED at `20d86e39`: expanded suite had 8 failures, all HOME-valued non-placement cases.
- GREEN: expanded suite passes 242/242.
- Round-10 probes: 7/7 placement expectations and 4/4 placement-option controls pass.
- Earlier path probes remain green: 60/60, 24/24, and 17/17.
- Historical discrimination with the 242-fixture suite:
- `3d0a882a`: 216 pass / 26 fail.
- `4b8eba95`: 222 pass / 20 fail.
- `20d86e39`: 234 pass / 8 fail.
- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass.
## Residual / risk
- Relative destinations whose effective path depends on cwd are tracked separately by #1197 and remain out of scope.
- Unknown future Git options with a separate following value fail closed when that value is HOME-shaped. This may require classification when Git adds an unrelated path-taking option, but prevents a new placement option from silently bypassing the guard.
@@ -85,6 +85,21 @@ FIXTURES="$TMP/fixtures.tsv"
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'
# Round ten: placement is decided by the destination and the one clone option
# that creates repository state elsewhere, not by every HOME-valued word in
# the command. Sources, templates, references, and environment are not targets.
printf '0\t{"tool_input":{"command":"NOTE=$HOME git clone https://example.invalid/x /src/wt"}}\tan unrelated assignment carrying HOME is not checkout placement\n'
printf '0\t{"tool_input":{"command":"git clone --reference=$HOME https://example.invalid/x /src/wt"}}\ta HOME reference is an object source, not checkout placement\n'
printf '0\t{"tool_input":{"command":"GIT_DIR=$HOME/x git clone https://example.invalid/x /src/wt"}}\tclone does not place its destination from ambient GIT_DIR\n'
printf '0\t{"tool_input":{"command":"git clone --template=$HOME/t https://example.invalid/x /src/wt"}}\ta HOME template source is not checkout placement\n'
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir=$HOME/gd https://example.invalid/x /src/wt"}}\tseparate-git-dir explicitly places repository state under HOME\n'
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir $HOME/gd https://example.invalid/x /src/wt"}}\tthe space-separated placement option is equivalent\n'
printf '0\t{"tool_input":{"command":"git worktree add --reason=$HOME/note /src/wt"}}\ta worktree reason is metadata, not its path\n'
printf '0\t{"tool_input":{"command":"git clone $HOME/source /src/wt"}}\ta HOME source with an explicit safe destination is not placement\n'
printf '0\t{"tool_input":{"command":"git clone --reference $HOME https://example.invalid/x /src/wt"}}\ta space-separated HOME reference remains a source\n'
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'
# 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.
@@ -100,13 +100,15 @@ normalize_command_words() {
-v protect_path_literals="$protect_path_literals" '
BEGIN {
state = "outside"; out = ""; word_start = 1
redirection = sprintf("%c", 25)
command_boundary = sprintf("%c", 26)
word_boundary = sprintf("%c", 27)
literal_dollar = sprintf("%c", 28)
literal_tilde = sprintf("%c", 29)
}
{
if (NR > 1) {
if (protect_path_literals) out = out word_boundary
if (protect_path_literals) out = out command_boundary
else out = out "\n"
word_start = 1
}
@@ -114,6 +116,12 @@ normalize_command_words() {
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 == redirection) {
out = out "\\x19"; word_start = 0; continue
}
if (protect_path_literals && c == command_boundary) {
out = out "\\x1a"; word_start = 0; continue
}
if (protect_path_literals && c == word_boundary) {
out = out "\\x1b"; word_start = 0; continue
}
@@ -154,9 +162,15 @@ normalize_command_words() {
}
continue
}
if (protect_path_literals && c ~ /[[:space:]|&;()<>]/) {
if (protect_path_literals && c ~ /[[:space:]]/) {
out = out word_boundary
word_start = 1
} else if (protect_path_literals && c ~ /[|&;()]/) {
out = out command_boundary
word_start = 1
} else if (protect_path_literals && c ~ /[<>]/) {
out = out word_boundary redirection word_boundary
word_start = 1
} else if (c == "\\") {
if (i == length($0)) {
out = out c
@@ -193,6 +207,8 @@ CMD_NAMES="$(printf '%s' "$CMD" | normalize_command_words 1 0)"
# 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_REDIRECTION=$'\031'
PATH_COMMAND_BOUNDARY=$'\032'
PATH_WORD_BOUNDARY=$'\033'
# The one place the shape of a program NAME is written down. Every name consumer
@@ -304,6 +320,118 @@ if [ "$home_known" -eq 1 ]; then
home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')"
fi
# Emit only paths the checkout syntax can PLACE. The previous whole-command
# match treated a HOME-valued environment assignment, reference, or template as
# the destination. Conversely, dropping `=` entirely lost clone's
# --separate-git-dir=<path>, which really does create repository state there.
#
# The path normalizer supplies three collision-safe lexical markers. Command
# markers bound each simple command; word markers split arguments without
# 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.
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 }
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_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 worktree_value_option(word) {
return word ~ /^(-b|-B|--reason|--orphan)$/
}
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) {
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)
if (value != "") print value
continue
}
if (options && word == "--separate-git-dir") {
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
}
positional[++positions] = word
}
# clone positional 1 is the source; every later positional can only be an
# explicit destination (or invalid excess input, which remains fail closed).
for (j = 2; j <= positions; j++) print positional[j]
}
function emit_worktree(start, count, i, word, options) {
options = 1
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
}
print word
return
}
}
{
delete raw; delete token
raw_count = 0
for (i = 1; i <= NF; i++) if ($i != "") raw[++raw_count] = $i
# Remove redirection operators and their operands. A numeric fd attached
# before the operator is not an argument either.
count = 0
for (i = 1; i <= raw_count; i++) {
if (i < raw_count && raw[i + 1] == rb && raw[i] ~ /^[0-9]+$/) {
i += 2
continue
}
if (raw[i] == rb) { i++; continue }
token[++count] = raw[i]
}
for (i = 1; i <= count; i++) {
if (!is_git(token[i])) continue
if (token[i + 1] == "clone") emit_clone(i + 2, count)
else if (token[i + 1] == "worktree" && token[i + 2] == "add") {
emit_worktree(i + 3, count)
}
}
}
'
}
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}git[[:space:]]+[^|;&]*(clone([[:space:]]|$)|worktree[[:space:]]+add([[:space:]]|$))"; then
if [ "$home_known" -eq 0 ]; then
cat <<EOF
@@ -322,12 +450,18 @@ derives the path and never consults \$HOME at all:
EOF
exit 2
fi
# 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
# A placement resolves to HOME when it is the exact home token or a descendant.
# Matching an extracted argument rather than the whole command is what keeps a
# HOME-valued source, option, or environment assignment from impersonating it.
placement_blocked=0
while IFS= read -r placement; do
[ -n "$placement" ] || continue
if printf '%s' "$placement" | grep -Eq "^($home_re)(/|$)"; then
placement_blocked=1
break
fi
done < <(checkout_placements)
if [ "$placement_blocked" -eq 1 ]; then
cat <<EOF
BLOCKED: this checks a repository out under \$HOME.