diff --git a/docs/scratchpads/1174-wrapper-guard-round10.md b/docs/scratchpads/1174-wrapper-guard-round10.md new file mode 100644 index 00000000..e6886619 --- /dev/null +++ b/docs/scratchpads/1174-wrapper-guard-round10.md @@ -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. diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index bc201aab..45a4d102 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -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. diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 570ec7b9..6e9ea867 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -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=, 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 <