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
+53
View File
@@ -1529,6 +1529,59 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
---
## Workspace placement guard hardening (#1174)
### Problem and objective
The Bash pre-tool guard must prevent Git checkouts and repository state from being placed under
`$HOME` without refusing ordinary Git commands merely because a source, option value, branch name,
or metadata mentions `$HOME`. A guard that over-blocks routine work is unsafe because operators
will route around it.
### Scope and requirements
1. `WPG-REQ-01`: `git clone` and `git worktree add` placement SHALL be judged from their placement
operands, not from every HOME-shaped word in the command.
2. `WPG-REQ-02`: Clone sources, references, templates, environment assignments, and non-placement
worktree metadata MAY resolve under HOME when all placement operands resolve elsewhere.
3. `WPG-REQ-03`: Both attached and separate-value `--separate-git-dir` forms SHALL remain placement
operands and SHALL be refused when they resolve under HOME.
4. `WPG-REQ-04`: Option classification SHALL account for Git's rule-generated boolean negations
without relying on an enumerable allowlist of flag spellings.
5. `WPG-REQ-05`: Quote removal, escapes, shell command boundaries, redirections, and end-of-options
handling SHALL preserve existing fail-closed checkout coverage.
6. `WPG-REQ-06`: Absolute placement aliases SHALL resolve shell-known HOME spellings, dot segments,
repeated separators, and existing symlink parents before the HOME boundary comparison.
7. Relative targets whose effective path depends on the shell cwd are out of scope and tracked by
#1197.
### Acceptance and verification
1. Git's own option parser accepts each tested flag, including generated `--no-*` forms, while the
guard allows a HOME-valued source with an explicit safe destination.
2. Equivalent clone and worktree fixtures cover rule-generated negations and remain discriminating
against the prior head where the defect existed.
3. Real HOME destinations and both `--separate-git-dir` forms remain blocked, including placements
after shell command boundaries.
4. The full hermetic guard suite, syntax/static checks, adversarial probes, independent review, and
terminal-green CI pass before merge.
5. Any option-classification residual is documented with its deliberate failure direction.
### Constraints, risks, and assumptions
- Security and usability are co-equal: neither a placement bypass nor routine over-block is an
acceptable repair.
- `ASSUMPTION:` The value-taking option surface exposed by the installed Git version is closed and
measurable through Git's own parser/help output; rationale: boolean flags are rule-generated,
while separate-value options have explicit grammar and must be classified as such.
- Risk: a future Git release may add a new value-taking placement option. Mitigation: document the
chosen residual direction and pin every currently supported placement option in behavior tests.
- Risk: a symlink can be replaced after pre-execution canonicalization. Mitigation: resolve every
existing parent physically and document the remaining inherent TOCTOU window; the worktree helper
remains the authoritative path-derivation mechanism, with atomic closure tracked by #1199.
---
## Assumptions
1. RESOLVED: **pgvector is sufficient** for semantic search at v0.1.0 scale (personal/family/team = thousands to low hundreds-of-thousands of vectors). `@mosaicstack/memory` defines a `VectorStore` interface with pgvector as the default adapter. The interface boundary makes Qdrant a drop-in migration if PG resource contention or scale demands it later. Zero additional infrastructure for v0.1.0. Rationale: Reduces ops burden; pgvector HNSW indexes are fast at this scale; interface abstraction costs almost nothing now.
+60 -1
View File
@@ -1,4 +1,4 @@
# #1174 — Wrapper guard round 10
# #1174 — Wrapper guard rounds 1011
## Objective
@@ -28,3 +28,62 @@ Make checkout enforcement judge Git placement operands rather than every HOME-sh
- 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.
## Round 11 objective and intake
- **Issue / PR:** #1174.
- **Objective:** Remove the finite boolean-flag allowlists that turn accepted clone/worktree flags into fake placement operands, while preserving all real HOME placement blocks.
- **Scope:** `wrapper-guard.sh`, its hermetic fixtures, and task documentation. Relative cwd-dependent destinations remain in #1197.
- **Surfaces:** security-sensitive Bash hook behavior and shell/Git option grammar; no API, DB, UI, auth, deploy, or dependency changes.
- **Budget assumption:** 25K working tokens; reduce exploratory matrices before reducing acceptance coverage.
### Round 11 plan
1. Use Git itself to classify accepted/rejected clone and worktree options, and Bash itself to resolve path-word expectations.
2. Add RED fixtures for all six reported clone flags, generated negations, and equivalent worktree grammar.
3. Replace the open-ended unknown-option fail-closed fallback with a parser based on the closed value-taking option surface; keep explicit placement options special.
4. Run the full corpus, historical discrimination, shell/static checks, targeted probes, independent code/security review, one push, and exact-head CI.
### Root-cause evidence
- Git 2.39.5 accepts all six reported clone flags and the broader generated family measured in the brief: `--bare`, `--mirror`, `--ipv4`, `--ipv6`, `-4`, `-6`, `--no-local`, `--no-reject-shallow`, `--no-bare`, `--no-sparse`, `--no-dissociate`, `--no-shallow-submodules`, `--no-quiet`, `--no-progress`, and `--no-recurse-submodules`; it rejects `--relative-paths` as unknown.
- Git 2.39.5 accepts worktree negations including `--no-force`, `--no-detach`, `--no-lock`, `--no-guess-remote`, and `--no-track`; the current finite worktree flag list does not describe that generated family.
- `bash -c "printf '%s' <word>"` resolves `$HOME/source`, `${HOME}/source`, and `"$HOME"/source` under HOME while `/src/wt` remains outside it.
- **Hypothesis:** only separate-value options need positive classification. Treat every other option token as a no-value flag unless it is the explicit placement option; this matches Git's non-enumerable boolean family and confines the residual to genuinely new future value-taking options.
### TDD and verification checkpoints
- RED against the unmodified `91cc37bc` guard: 253 pass / 22 fail in the initial expanded 275-fixture suite. Failures include all 15 accepted clone flags, accepted long abbreviations, short value-taking bundles, abbreviated placement, worktree metadata abbreviation, and both directions of bundled worktree branch parsing.
- An exploratory fail-closed residual test drove emission of every worktree positional. Re-review correctly showed that this over-blocked HOME-shaped commit-ish metadata; a new commit-ish fixture failed RED against that intermediate implementation (278 pass / 2 fail, including one transient message assertion) and the parser was restored to emit only the actual path.
- GREEN after remediation: 280/280.
- Ultron's 13-shape option probe: 13/13 correct, including the six reported over-blocks, HOME destinations, end-of-options, worktree controls, and a later-command placement.
- Round-10 probes remain green: 7/7 subject-placement expectations and 4/4 `--separate-git-dir` controls.
- Earlier shell/path probes remain green: 60/60, 24/24, and 17/17.
- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass.
### Deliberate residual
A future Git release could add a new separate-value option absent from the closed value grammar. It defaults to no-value flag parsing, which leaves the following word positional. For clone, this can fail open if that future option itself creates repository state at its value. For worktree, it can shift which word is read as the path. This hypothetical future ambiguity is accepted deliberately because failing closed on every unclassified option is proven to over-block Git's open-ended present-day boolean/`--no-*` family. Every value-taking and placement option Git currently supports is classified, including accepted abbreviations of `--separate-git-dir`. Relative cwd-dependent targets remain in #1197.
### Independent review checkpoint
- Initial Codex code/security review raised `--orphan` as value-taking. Upstream Git `master` contradicts that premise: the synopsis is `[--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]`, and the prose derives the branch from the path when `-b`/`-B` is absent. `--orphan` is therefore correctly handled as a boolean flag.
- The security review separately identified the generic future worktree shift residual. An attempted fail-closed remediation emitted every positional, but code re-review correctly rejected it because valid grammar has only one placement positional and an optional commit-ish. Final behavior checks only the path and documents the hypothetical future option shift deliberately; paired actual-grammar `--orphan` fixtures cover safe/HOME paths and `-b` metadata.
- Security re-review initially had no findings. Code re-review's commit-ish blocker was remediated with a RED fixture and path-only restoration; final code re-review approved with no findings.
- Final security review then found non-canonical absolute and symlink aliases. Eight lexical fixtures failed RED against the prior implementation, followed by three symlink fixtures failing RED. Remediation expands only shell-visible HOME tokens, resolves the longest existing directory prefix physically, and lexically normalizes the nonexistent suffix. The suite is now 292/292.
- Inherent residual: a symlink can be replaced between pre-tool inspection and Git execution. Existing aliases are resolved; eliminating the race requires enforcement inside the filesystem mutation path rather than a text pre-hook. Security review classified this medium, and architectural closure is tracked in #1199.
- Final independent code review: APPROVE, 0 findings. Final security review: no critical/high findings; the single medium TOCTOU residual is explicitly tracked in #1199.
### Final local evidence
- Final hermetic suite: 292/292; the same suite against `91cc37bc` discriminates at 256 pass / 36 fail.
- Ultron option probe: 13/13; round-10 probes: 7/7 plus 4/4 controls; earlier shell/path probes: 60/60, 24/24, and 17/17.
- `bash -n`, ShellCheck warning-or-higher, `git diff --check`, sanitization gate, and test-enumeration gate (population 55; 38 enumerated; 18 signed exclusions): pass.
- Independent code review: APPROVE, 0 findings. Security review's remaining medium TOCTOU architecture residual is tracked in #1199; no critical/high findings remain.
- Repository-wide TypeScript gates require dependencies absent from this worktree; the canonical Woodpecker pipeline will run them against the pushed exact head.
### Documentation checklist
- `docs/PRD.md` updated with WPG requirements, acceptance, canonicalization, and residual risk.
- Task scratchpad updated in the same logical change set; `docs/TASKS.md` remains orchestrator-only.
- No API, auth, UI, navigation, deployment, user-guide, or admin-guide surface changed; OpenAPI, endpoint index, sitemap, and publishing are not applicable.
@@ -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