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:
@@ -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'"'"'"}}\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 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'
|
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
|
# 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
|
# 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.
|
# 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" '
|
-v protect_path_literals="$protect_path_literals" '
|
||||||
BEGIN {
|
BEGIN {
|
||||||
state = "outside"; out = ""; word_start = 1
|
state = "outside"; out = ""; word_start = 1
|
||||||
|
redirection = sprintf("%c", 25)
|
||||||
|
command_boundary = sprintf("%c", 26)
|
||||||
word_boundary = sprintf("%c", 27)
|
word_boundary = sprintf("%c", 27)
|
||||||
literal_dollar = sprintf("%c", 28)
|
literal_dollar = sprintf("%c", 28)
|
||||||
literal_tilde = sprintf("%c", 29)
|
literal_tilde = sprintf("%c", 29)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
if (NR > 1) {
|
if (NR > 1) {
|
||||||
if (protect_path_literals) out = out word_boundary
|
if (protect_path_literals) out = out command_boundary
|
||||||
else out = out "\n"
|
else out = out "\n"
|
||||||
word_start = 1
|
word_start = 1
|
||||||
}
|
}
|
||||||
@@ -114,6 +116,12 @@ normalize_command_words() {
|
|||||||
c = substr($0, i, 1)
|
c = substr($0, i, 1)
|
||||||
# A raw marker byte is ordinary word content. Encode it visibly so only
|
# A raw marker byte is ordinary word content. Encode it visibly so only
|
||||||
# this machine can manufacture an internal boundary marker.
|
# 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) {
|
if (protect_path_literals && c == word_boundary) {
|
||||||
out = out "\\x1b"; word_start = 0; continue
|
out = out "\\x1b"; word_start = 0; continue
|
||||||
}
|
}
|
||||||
@@ -154,9 +162,15 @@ normalize_command_words() {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (protect_path_literals && c ~ /[[:space:]|&;()<>]/) {
|
if (protect_path_literals && c ~ /[[:space:]]/) {
|
||||||
out = out word_boundary
|
out = out word_boundary
|
||||||
word_start = 1
|
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 == "\\") {
|
} else if (c == "\\") {
|
||||||
if (i == length($0)) {
|
if (i == length($0)) {
|
||||||
out = out c
|
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
|
# from a different variable or command substitution is absent from the literal text and
|
||||||
# remains outside a text guard's visibility.
|
# remains outside a text guard's visibility.
|
||||||
CMD_PATHS="$(printf '%s' "$CMD" | normalize_command_words 0 1)"
|
CMD_PATHS="$(printf '%s' "$CMD" | normalize_command_words 0 1)"
|
||||||
|
PATH_REDIRECTION=$'\031'
|
||||||
|
PATH_COMMAND_BOUNDARY=$'\032'
|
||||||
PATH_WORD_BOUNDARY=$'\033'
|
PATH_WORD_BOUNDARY=$'\033'
|
||||||
|
|
||||||
# The one place the shape of a program NAME is written down. Every name consumer
|
# 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')"
|
home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')"
|
||||||
fi
|
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 printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}git[[:space:]]+[^|;&]*(clone([[:space:]]|$)|worktree[[:space:]]+add([[:space:]]|$))"; then
|
||||||
if [ "$home_known" -eq 0 ]; then
|
if [ "$home_known" -eq 0 ]; then
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -322,12 +450,18 @@ derives the path and never consults \$HOME at all:
|
|||||||
EOF
|
EOF
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
# Any argument that resolves to $HOME itself or beneath it. The path-mode
|
# A placement resolves to HOME when it is the exact home token or a descendant.
|
||||||
# normalizer marks every unquoted POSIX shell word boundary, so the home token
|
# Matching an extracted argument rather than the whole command is what keeps a
|
||||||
# must be preceded by a boundary/start/assignment and followed by a boundary,
|
# HOME-valued source, option, or environment assignment from impersonating it.
|
||||||
# slash (a descendant), or end. Arbitrary filename bytes remain content; this
|
placement_blocked=0
|
||||||
# avoids over-blocking sibling names such as $HOME+bak or $HOME.bak.
|
while IFS= read -r placement; do
|
||||||
if printf '%s' "$CMD_PATHS" | grep -Eq "(^|=|$PATH_WORD_BOUNDARY)($home_re)($PATH_WORD_BOUNDARY|/|$)"; then
|
[ -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
|
cat <<EOF
|
||||||
BLOCKED: this checks a repository out under \$HOME.
|
BLOCKED: this checks a repository out under \$HOME.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user