diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 63fb349d..26ea0848 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -46,10 +46,28 @@ steps: # [0] of the pnpm chain, so severing that chain would silence it together # with everything it guards; this direct line keeps one instrument running. - bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh + # Tool-index gate: a shipped wrapper that appears in no resident index doc + # is undiscoverable from inside a session, and an agent that cannot learn a + # wrapper exists reaches for raw curl instead — which is how a Gitea review + # got filed PENDING three times. Ships-and-documented is one commit, or red. + - bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test + - bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh # Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH # and sandboxes a throwaway git repo, so it resolves no real credentials and # joins CI directly rather than the exclusions file. - bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh + # Hermetic behavioural regression for the PreToolUse wrapper guard: proves + # it still blocks the three mistakes AND still lets reads, unwrapped + # endpoints and ordinary commands through. Both directions are asserted — + # a guard that over-blocks gets routed around, which fails just as hard. + - bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh + # Hermetic regression for mosaic-worktree.sh at fleet scale: stubs git onto + # PATH so `list` faces ~450 KB of porcelain. The defect it pins is invisible + # at small size — `git … | awk '…exit'` gives the producer SIGPIPE, which + # under `set -euo pipefail` aborts the caller silently with rc=141 and no + # output. A repo only reaches that once it has enough worktrees, so the + # stub supplies the scale instead of the host's own checkout. + - bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh # Blocking gate (#791): a framework upgrade must never write or delete an # operator-owned path. The HARD GATE proves an unanticipated operator sentinel diff --git a/docs/PRD.md b/docs/PRD.md index a551ca73..40b99edd 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1538,6 +1538,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. diff --git a/docs/scratchpads/1174-wrapper-guard-round10.md b/docs/scratchpads/1174-wrapper-guard-round10.md new file mode 100644 index 00000000..3629652f --- /dev/null +++ b/docs/scratchpads/1174-wrapper-guard-round10.md @@ -0,0 +1,89 @@ +# #1174 — Wrapper guard rounds 10–11 + +## 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. + +## 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' "` 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) ] []`, 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. diff --git a/packages/mosaic/framework/defaults/STANDARDS.md b/packages/mosaic/framework/defaults/STANDARDS.md index d9ea7f40..43d1c8ba 100644 --- a/packages/mosaic/framework/defaults/STANDARDS.md +++ b/packages/mosaic/framework/defaults/STANDARDS.md @@ -52,6 +52,52 @@ If a repo does not expose these scripts, run equivalent local workflow commands - Do not auto-resolve data conflicts in shared state files. - Keep commits scoped to a single logical change set. +## Model Tiering + +Model choice is a standard, not a preference. Delegating a mechanical grep to a +frontier reasoning model wastes budget; sending a security review to a cheap tier +produces a review that passes and proves nothing. Both are defects. + +Tiers are named by **capability class**, so the standard survives a model +generation. An operator binds each class to a concrete model id. + +| Class | Use for | +| ------------- | ----------------------------------------------------------------------------------------- | +| `search` | grep/glob, file location, status and health checks, one-line mechanical edits | +| `build` | feature implementation, test writing, bugfixes, routine refactors | +| `judge` | code review, planning, API/compat-sensitive changes | +| `adversarial` | security review, ambiguous architecture, anything where a wrong "looks fine" is expensive | + +Rules: + +1. **Start at the cheapest class that can do the task; escalate on evidence, not + on nerves.** Omitting a tier is not neutral — it inherits the caller's model, + which is usually the most expensive one. +2. **Compat-sensitive work escalates one class.** A change that must interoperate + with an existing contract is judged, not just built. +3. **A tier assignment is benchmarked, not asserted.** Move a task class to a + cheaper tier only against a blind A/B on real work from this codebase, ranked + by someone other than the author. "It seemed fine" is not evidence. +4. **Reviewer independence beats reviewer size.** An `adversarial` verdict from + the model that wrote the code is not a second opinion (see Constitution gate 16). + +### Where the binding lives + +The class→model map is operator configuration, never framework source: model +availability, cost, and quotas differ per operator and per host. + +Resolution order, first hit wins: + +1. the config service (DB-backed, surfaced and editable in the Mosaic webUI) +2. a local operator file (`STANDARDS.local.md`, or `policy/` where the runtime + injects it) +3. the framework default — the class names above, with no binding + +Only layer 1 is auditable across a fleet, so it is the target end state; layers 2 +and 3 exist so a host with no config service still runs. A local override that +silently disagrees with the config service is drift — the same failure class the +tool-index gate exists to catch, and it belongs in `mosaic doctor`. + ## Prompting Contract All runtime adapters should inject: diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index 0eca6c5a..df2379ad 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -11,22 +11,105 @@ All tool suites are located at `~/.config/mosaic/tools/`. Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands. +This index is complete and is kept complete mechanically: `tools/quality/scripts/check-tools-index.sh` +fails CI when a wrapper ships without an entry here, or when an entry here names a wrapper that no +longer exists. A wrapper missing from this list is, from inside an agent session, indistinguishable +from a wrapper that was never written — which is how the APPROVE/APPROVED incident below happened. + +Every command takes `--help`. All of them accept `--login ` to pin the acting identity; +supply it explicitly on any host where the provider CLI's default account is an admin. + +| Issues | | +| ------------------ | --------------------------------- | +| `issue-create.sh` | Create an issue (Gitea or GitHub) | +| `issue-view.sh` | Show one issue | +| `issue-list.sh` | List issues | +| `issue-edit.sh` | Edit title/body/labels/milestone | +| `issue-comment.sh` | Add a comment | +| `issue-assign.sh` | Assign or unassign | +| `issue-close.sh` | Close an issue | +| `issue-reopen.sh` | Reopen a closed issue | + +| Pull requests | | +| ---------------- | --------------------------------------------------------- | +| `pr-create.sh` | Open a pull request | +| `pr-view.sh` | Show one PR | +| `pr-list.sh` | List PRs | +| `pr-diff.sh` | Fetch a PR's diff | +| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) | +| `pr-review.sh` | **Place a review verdict — see the dialect note below** | +| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state | +| `pr-merge.sh` | Merge a PR | +| `pr-close.sh` | Close a PR without merging | + +| Milestones | | +| --------------------- | ------------------ | +| `milestone-create.sh` | Create a milestone | +| `milestone-list.sh` | List milestones | +| `milestone-close.sh` | Close a milestone | + +| Gates and guards | | +| ----------------------- | --------------------------------------------------------------------------------------------------------- | +| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) | +| `push-guard.sh` | Refuse verifications that pass for the wrong reason (e.g. green against an unpushed tree) | +| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard | +| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree | + +| Context | | +| -------------------- | ---------------------------------------------------------------------------------------- | +| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it | +| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider | + +| Workspace | | +| -------------------- | ------------------------------------------------------------------------ | +| `mosaic-worktree.sh` | Create/list/remove git worktrees — **the only supported way**; see below | +| `wrapper-guard.sh` | PreToolUse hook that enforces the two rules above; not called by hand | + +**Workspace placement is derived, not chosen.** `mosaic-worktree.sh new ` takes a branch +name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree, +repo name, parent dir, then `/-worktrees/`. There is no placement flag +because a decision an agent has to make is a decision that drifts: the rule "big work goes on a work +filesystem" already existed in prose and 255 GB accumulated in `$HOME` across 842 directories +anyway, under five simultaneous conventions on a single host. + ```bash -# Issues -~/.config/mosaic/tools/git/issue-create.sh -~/.config/mosaic/tools/git/issue-close.sh +~/.config/mosaic/tools/git/mosaic-worktree.sh new [--from ] +~/.config/mosaic/tools/git/mosaic-worktree.sh path # derived path, no side effect +~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state +~/.config/mosaic/tools/git/mosaic-worktree.sh rm # removal is part of the task +~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones +``` -# PRs -~/.config/mosaic/tools/git/pr-create.sh -~/.config/mosaic/tools/git/pr-merge.sh +Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare +clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and +`gc` decide by **evidence, never by size or age**: a worktree is reclaimable only when +`git status --porcelain` is empty _and_ `git rev-list --count HEAD --not --remotes` is 0. Anything +else is preserved and reported. `--force` exists and is yours to type deliberately. -# Milestones -~/.config/mosaic/tools/git/milestone-create.sh +`wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see +`runtime/claude/settings.json`). It blocks exactly three things and lets everything else through: +a `git clone`/`git worktree add` targeting `$HOME`; a raw provider-API **write** to an endpoint that +already has a wrapper above (reads are untouched — they are how you gather evidence); and the +literal `"event": "APPROVE"`. For a genuine gap no wrapper can express, prefix +`MOSAIC_WRAPPER_OVERRIDE=1`. Reaching for the override twice for the same call means the wrapper has +a missing flag — extend the wrapper. + +```bash +~/.config/mosaic/tools/git/issue-create.sh --help +~/.config/mosaic/tools/git/pr-review.sh --pr 42 --event APPROVED --body "..." # CI queue guard (required before push/merge; defaults to the checked-out branch) ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge ``` +**Review dialect — the reason `pr-review.sh` is not optional.** Gitea's approve event is +`APPROVED`; GitHub's is `APPROVE`. Send GitHub's spelling to a Gitea host and it answers **HTTP +200**, files the review as PENDING, and then rejects the submit with `422 review stay pending` — the +verdict looks placed and is not. (`REQUEST_CHANGES` is spelled identically on both, so only the +approve path carries the trap.) `pr-review.sh` sends the correct token for the detected provider. +Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict +placed. + The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head ` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`. ### Code Review (Codex) diff --git a/packages/mosaic/framework/runtime/claude/settings.json b/packages/mosaic/framework/runtime/claude/settings.json index eada96fe..1f60a7c3 100644 --- a/packages/mosaic/framework/runtime/claude/settings.json +++ b/packages/mosaic/framework/runtime/claude/settings.json @@ -52,6 +52,16 @@ "timeout": 10 } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "~/.config/mosaic/tools/git/wrapper-guard.sh", + "timeout": 10 + } + ] } ], "PostToolUse": [ diff --git a/packages/mosaic/framework/tools/git/mosaic-worktree.sh b/packages/mosaic/framework/tools/git/mosaic-worktree.sh new file mode 100755 index 00000000..926365eb --- /dev/null +++ b/packages/mosaic/framework/tools/git/mosaic-worktree.sh @@ -0,0 +1,306 @@ +#!/usr/bin/env bash +# mosaic-worktree.sh — the only supported way to create and dispose of a git +# worktree on a fleet host. +# +# Why this exists as a helper and not as a rule: the rule already existed, in +# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in +# $HOME across 842 directories anyway. Five placement conventions were live on +# one fleet host simultaneously. Every one was a decision an agent had to make, +# and a decision an agent has to make is a decision that drifts. +# +# So this script makes NO placement decision available. The caller supplies a +# branch name. Every path is DERIVED: +# +# main worktree <- git worktree list --porcelain (never cwd, which may +# itself already be a worktree) +# REPO_NAME <- basename of the main worktree +# REPO_PARENT <- dirname of the main worktree +# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees +# SLUG <- branch with '/' replaced by '-' +# WT_PATH <- $WT_ROOT/$SLUG +# +# The derivation puts the worktree on the same filesystem as the object store +# it shares, as a sibling of the repo, under one root per repo. Those are the +# properties that make the checkout cheap and — via `git worktree list` — +# enumerable, which is the only reason automated cleanup can ever be safe. +# +# Usage: +# mosaic-worktree.sh new [--from ] create (branch may exist) +# mosaic-worktree.sh path print derived path, no side effect +# mosaic-worktree.sh list this repo's worktrees + state +# mosaic-worktree.sh rm [--force] remove; refuses to lose work +# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees +# +# `rm` and `gc` refuse to delete a worktree with uncommitted changes, with +# commits absent from every remote, or holding ignored files that are not of the +# well-known regenerable kind (a `.env` is ignored so it is never committed, +# which is also why nothing else holds a copy). That check is by EVIDENCE, never +# by size or age. --force overrides it and is yours to type deliberately. +# +# Run from anywhere inside the repo, or pass --repo . + +set -euo pipefail + +die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; } + +REPO_HINT="" +ARGS=() +while [ $# -gt 0 ]; do + case "$1" in + --repo) REPO_HINT="${2:-}"; shift 2 ;; + *) ARGS+=("$1"); shift ;; + esac +done +set -- "${ARGS[@]+"${ARGS[@]}"}" + +CMD="${1:-}" +[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc" +shift || true + +# ---- mechanical derivation ------------------------------------------------- +# The FIRST entry of `git worktree list --porcelain` is always the main +# worktree, regardless of which worktree we are standing in. Deriving from cwd +# would nest worktrees inside worktrees. +resolve_repo() { + local start="${REPO_HINT:-$PWD}" + git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \ + || die "not inside a git repository: $start" + # Take the first entry WITHOUT closing the pipe early. `awk ... exit` on the + # first match closes the read end while git is still writing, git takes SIGPIPE, + # and under `set -euo pipefail` the command substitution returns 141 and this + # function aborts SILENTLY — no message, no worktree, and `new` exits 141 while + # printing nothing at all. + # + # Whether it happens depends on how much git still had to write when awk left, + # so the failure is a function of REPO SIZE: fine on a repo with three + # worktrees, reliably broken on one with seventy. That is backwards — the repos + # this helper exists to serve are exactly the ones that accumulated worktrees, + # and it silently did nothing on those while working everywhere it was tried. + # Measured on a repo with 73 worktrees (10 KB of porcelain): rc=141, no output. + # + # The file's own comment block below already names this class for `head -200` + # and removed that cap for the same reason. The `exit` here is the same defect + # in the same file, so the rule is now uniform: nothing in this script closes a + # git pipe early. Dropping `exit` costs one pass over a few KB. + MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /&&!seen{print substr($0,10); seen=1}')" + [ -n "$MAIN_WT" ] || die "could not resolve the main worktree" + REPO_NAME="$(basename -- "$MAIN_WT")" + REPO_PARENT="$(dirname -- "$MAIN_WT")" + WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees" +} + +slugify() { printf '%s' "$1" | tr '/' '-'; } + +derive_path() { + local branch="$1" + [ -n "$branch" ] || die "branch name required" + printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")" +} + +# A worktree root under $HOME defeats the entire point: wrong filesystem, and +# $HOME is for configuration and state, not work products. Refuse rather than +# silently produce the layout we are trying to eliminate. +assert_not_home() { + local p="$1" home_real repo_real + home_real="$(cd "$HOME" && pwd -P)" + repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")" + case "$repo_real/" in + "$home_real"/*) + die "refusing: derived path is under \$HOME ($p). +The repo itself lives under \$HOME, so its worktrees would too. Move the repo +to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds +configuration, credentials, state and caches — not checkouts." ;; + esac +} + +# ---- work-loss evidence ---------------------------------------------------- +# Two independent questions, both answered from git, neither from size or age: +# dirty — anything uncommitted in the tree +# unpushed — commits reachable from HEAD that no remote ref contains +# precious — IGNORED files git will not mention and will not miss +# +# The third question is not obvious and was missed on the first pass. An +# independent reviewer demonstrated it in four commands: a pushed, clean +# worktree whose .gitignore covers `*.secret`, holding one `local.secret`. +# `git status --porcelain` is empty, `rev-list --count HEAD --not --remotes` is +# 0 — the evidence reads SAFE — and `git worktree remove` deletes the file. The +# same shape covers `.env`, credentials, scratch notes, downloaded fixtures: +# precisely the files that are ignored BECAUSE they must not be committed, which +# is also why nothing else is holding a copy. +# +# So ignored files count as work unless they are the well-known regenerable +# kind. Getting that set wrong is asymmetric: an over-broad list preserves a +# worktree that could have been reclaimed (cheap, visible, fixable by --force), +# an over-narrow one deletes the only copy of a secret (silent, permanent). +# The list stays short and conservative for that reason. +DISPOSABLE_RE='(^|/)(node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.turbo|\.cache|\.parcel-cache|\.gradle|dist|build|out|target|coverage|\.next|\.nuxt|\.svelte-kit)(/|$)|\.(pyc|pyo|o|class)$' + +# These three run under `set -euo pipefail` inside command substitution, which +# makes any nonzero exit ANYWHERE in the pipeline abort the calling function +# silently. Two ways that bites, one of which shipped: +# +# * `grep -v` exits 1 when it filters everything out. A worktree whose only +# ignored entry is `node_modules/` is exactly the SAFE case, and it made +# `rm` exit 1 with no message and no removal — found by review. +# * `head -200` closes the pipe, SIGPIPEs the producer, and turns a worktree +# with 201 dirty files into the same silent abort. Not reported; it is the +# same defect one step upstream, so the cap is gone. Counting is cheap; +# the cap only ever protected output that is now never printed. +# +# Every one of them therefore ends in a total, and every stage that can +# legitimately exit nonzero says so explicitly. +wt_dirty() { + local out + out="$(git -C "$1" status --porcelain 2>/dev/null || true)" + if [ -n "$out" ]; then printf '%s\n' "$out" | wc -l; else printf '0'; fi +} + +wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || printf '?'; } + +# Default --ignored (not =matching) so a 40k-file node_modules collapses to one +# directory entry instead of being enumerated and then discarded. +wt_precious() { + local ignored + ignored="$(git -C "$1" status --porcelain --ignored 2>/dev/null \ + | awk '/^!! /{print substr($0,4)}' || true)" + [ -n "$ignored" ] || { printf '0'; return 0; } + printf '%s\n' "$ignored" | grep -Ecv "$DISPOSABLE_RE" || true +} + +wt_state() { + local wt="$1" d u p + d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"; p="$(wt_precious "$wt")" + if [ "$d" -eq 0 ] && [ "$u" = "0" ] && [ "$p" -eq 0 ]; then + printf 'SAFE\tclean; 0 unpushed; no ignored files worth keeping' + else + printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p" + fi +} + +# ---- commands -------------------------------------------------------------- +cmd_path() { resolve_repo; derive_path "${1:-}"; echo; } + +cmd_new() { + local branch="${1:-}" base="" + shift || true + while [ $# -gt 0 ]; do + case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac + done + [ -n "$branch" ] || die "usage: mosaic-worktree.sh new [--from ]" + + resolve_repo + local path; path="$(derive_path "$branch")" + assert_not_home "$path" + + if [ -e "$path" ]; then + echo "exists: $path" + echo "(already checked out — reuse it, or 'rm' it first)" + return 0 + fi + + mkdir -p "$WT_ROOT" + + # Existing branch -> check it out. New branch -> create from base (default: + # the remote's default branch if resolvable, else current HEAD). + if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \ + || git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then + git -C "$MAIN_WT" worktree add "$path" "$branch" + else + if [ -z "$base" ]; then + base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" + [ -n "$base" ] || base="HEAD" + fi + git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base" + fi + + cat < [--force]" + + resolve_repo + local path; path="$(derive_path "$branch")" + [ -d "$path" ] || die "no worktree at $path" + + local d u p + d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"; p="$(wt_precious "$path")" + if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ] || [ "$p" -ne 0 ]; }; then + die "refusing to remove $path + uncommitted files: $d + unpushed commits: $u + ignored, not disposable: $p +Commit and push first — that is the contract. Ignored files are counted because +git will neither report them nor miss them: a .env or a *.secret is ignored +precisely so it is never committed, which is also why nothing else holds a copy. +List them with: git -C $path status --porcelain --ignored | grep '^!!' +If this work is genuinely disposable, re-run with --force." + fi + + # NB: ${force:+--force} would expand for force=0 too ("0" is non-empty). + if [ "$force" -eq 1 ]; then + git -C "$MAIN_WT" worktree remove --force "$path" + else + git -C "$MAIN_WT" worktree remove "$path" + fi + git -C "$MAIN_WT" worktree prune + echo "removed: $path" + rmdir "$WT_ROOT" 2>/dev/null || true +} + +cmd_gc() { + local apply=0 + [ "${1:-}" = "--apply" ] && apply=1 + resolve_repo + git -C "$MAIN_WT" worktree prune + git -C "$MAIN_WT" worktree list --porcelain \ + | awk '/^worktree /{print substr($0,10)}' \ + | while read -r wt; do + [ "$wt" = "$MAIN_WT" ] && continue + local_state="$(wt_state "$wt")" + case "$local_state" in + SAFE*) + if [ "$apply" -eq 1 ]; then + git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt" + else + echo "reclaimable (clean + fully pushed): $wt" + fi ;; + *) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;; + esac + done + git -C "$MAIN_WT" worktree prune + [ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)' +} + +case "$CMD" in + new) cmd_new "$@" ;; + path) cmd_path "$@" ;; + list) cmd_list "$@" ;; + rm) cmd_rm "$@" ;; + gc) cmd_gc "$@" ;; + -h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;; + *) die "unknown command: $CMD (new | path | list | rm | gc)" ;; +esac diff --git a/packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh b/packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh new file mode 100755 index 00000000..5b2eef7b --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# test-mosaic-worktree-large-repo.sh — the helper must work on the repos it exists for. +# +# resolve_repo() took the first line of `git worktree list --porcelain` with +# `awk '/^worktree /{print substr($0,10); exit}'`. The `exit` closes the read end +# of the pipe while git is still writing, git takes SIGPIPE, and under +# `set -euo pipefail` the command substitution returns 141 — so the assignment +# fails, `set -e` aborts the function, and the script dies printing NOTHING. No +# message, no path, no worktree, exit 141. +# +# What makes it worth a dedicated test rather than a fixture line is WHEN it +# fires. If git finishes writing before awk leaves, there is no SIGPIPE and +# everything works. So the failure is a function of how much porcelain the repo +# produces: invisible on a three-worktree repo, reliable on a seventy-worktree +# one. It was measured on a repo with 73 worktrees (10 KB of porcelain) — rc=141, +# no output — and it had passed every hand-check before that, on small repos. +# +# A test that ran `git worktree list` against whatever repo it happens to sit in +# would inherit that same size dependence and would have PASSED on the tree that +# was broken. So git is stubbed on PATH and made to emit a large porcelain +# stream, which turns "depends on the repo you are standing in" into "always". +# +# Exit: 0 = the helper resolved the repo · 1 = it did not + +set -uo pipefail + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +TOOL="${1:-$HERE/mosaic-worktree.sh}" +[ -x "$TOOL" ] || { printf 'test-mosaic-worktree-large-repo: not executable: %s\n' "$TOOL" >&2; exit 2; } + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" + +# The stub answers exactly the two calls resolve_repo makes, and answers the +# porcelain one with ~450 KB — comfortably past a 64 KB pipe buffer, so the +# writer is still writing when a reader that quits early goes away. Anything +# else exits non-zero rather than pretending to be git. +cat > "$TMP/bin/git" <<'STUB' +#!/bin/sh +while [ $# -gt 0 ]; do + case "$1" in -C) shift 2 ;; *) break ;; esac +done +case "$*" in + "rev-parse --git-dir") + echo .git; exit 0 ;; + "worktree list --porcelain") + # The first entry is the main worktree. That single line is all the helper + # needs, and it is exactly what it stopped receiving. + printf 'worktree /src/fakerepo\nHEAD %040d\nbranch refs/heads/main\n\n' 0 + awk 'BEGIN{ for (i = 0; i < 4000; i++) + printf "worktree /src/fakerepo-worktrees/w%d\nHEAD %040d\nbranch refs/heads/topic-%d\n\n", i, 0, i }' + # NOT `exit 0`. Real git dies of SIGPIPE here and reports 141, and pipefail + # in the caller is what turns that into the silent abort. A stub that exits 0 + # regardless hands the caller a clean status and the probe passes on the + # broken tree — which is how this test failed to be a test on its first run. + exit $? ;; +esac +exit 1 +STUB +chmod +x "$TMP/bin/git" + +fail=0 +check() { + local why="$1" want="$2" got="$3" + if [ "$want" = "$got" ]; then + printf 'ok %s\n' "$why" + else + printf 'FAIL %s\n want: %s\n got: %s\n' "$why" "$want" "$got" + fail=1 + fi +} + +out="$(PATH="$TMP/bin:$PATH" "$TOOL" path feat/workspace-hygiene 2>&1)" +rc=$? + +# Both halves are asserted. rc alone would pass if the helper started printing a +# usage error, and output alone would miss a non-zero exit — and the defect's +# signature is precisely a non-zero exit with no output, which only the pair +# distinguishes from every other way this could go wrong. +check 'resolving a repo with a large worktree list exits 0' 0 "$rc" +check 'and derives the path from the main worktree' /src/fakerepo-worktrees/feat-workspace-hygiene "$out" + +printf '\n' +if [ "$fail" -eq 0 ]; then + printf 'mosaic-worktree: resolves against a large porcelain stream.\n' +else + cat <<'EOF' +mosaic-worktree could not resolve the repository. + +An empty output with a non-zero exit is the SIGPIPE signature: a reader that +quits early (`awk ... exit`, `head -n`) kills the producer, and pipefail turns +that into a silent abort. Nothing in this script may close a git pipe early. +EOF +fi +exit "$fail" diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh new file mode 100755 index 00000000..a72fe1e1 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -0,0 +1,703 @@ +#!/usr/bin/env bash +# test-wrapper-guard.sh — hermetic behavioural regression for wrapper-guard.sh. +# +# Resolves no credentials, touches no network, and creates no repository: the +# guard reads a hook payload on stdin and answers with an exit code, so the whole +# contract is testable from fixtures. +# +# The fixtures are written to a temp file rather than passed inline, and this is +# not stylistic. The guard inspects the literal text of the Bash command it is +# handed. A test that embeds `git clone ... $HOME` inside its own command line +# trips the guard on the harness instead of on the fixture — which is exactly +# what happened the first time this was checked by hand. Substring matching over +# whole command text is the guard's deliberate fail-closed posture; a test that +# does not account for it silently measures the wrong thing. +# +# Exit: 0 = every fixture behaved as specified · 1 = at least one did not + +set -uo pipefail + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +GUARD="${1:-$HERE/wrapper-guard.sh}" +[ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; } + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +FIXTURES="$TMP/fixtures.tsv" + +# Each line: TAB TAB +# [ TAB ] +# 0 = allowed, 2 = blocked. The optional fourth field is how the remediation +# itself gets checked; without it a block is only asserted to have happened, +# not to have been useful. +{ + printf '2\t{"tool_input":{"command":"git clone https://example.invalid/x ~/wt"}}\tcheckout into $HOME is refused\n' + printf '2\t{"tool_input":{"command":"git worktree add ~/wt topic"}}\tworktree into $HOME is refused\n' + printf '2\t{"tool_input":{"command":"g\\"it\\" clone https://example.invalid/x $HOME/wt"}}\ta double quote inside git does not hide a checkout\n' + printf '2\t{"tool_input":{"command":"g'"'"'it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta single quote inside git does not hide a checkout\n' + printf '2\t{"tool_input":{"command":"g\\\\it clone https://example.invalid/x $HOME/wt"}}\tan unquoted escape inside git does not hide a checkout\n' + # Path words use the same quote/escape state machine as names, but preserve + # substitutions so HOME remains visible. Quotes do not split the path word. + 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' + printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\""}}\tquotes do not change the exact HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x ${HOME}"}}\tthe braced variable may name HOME exactly\n' + printf '2\t{"tool_input":{"command":"git clone x ~"}}\ttilde may name HOME exactly\n' + printf '2\t{"tool_input":{"command":"git worktree add $HOME topic"}}\twhitespace terminates an exact HOME target before another argument\n' + # Unquoted POSIX metacharacters terminate the target word even without spaces. + printf '2\t{"tool_input":{"command":"git clone x $HOME;echo x"}}\tsemicolon terminates an exact HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"&& echo x"}}\tand-if terminates a quoted exact HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x ${HOME}| cat"}}\ta pipe terminates a braced exact HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x ~&"}}\tbackground operator terminates a tilde HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x $HOMEout"}}\toutput redirection terminates the target word\n' + printf '2\t{"tool_input":{"command":"( git clone x $HOME)"}}\ta subshell close terminates the exact HOME target\n' + printf '2\t{"tool_input":{"command":"git clone x $HOME\\necho x"}}\ta literal newline terminates the target word\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME_BACKUP/wt"}}\ta longer HOME-prefixed variable is a different path\n' + printf '0\t{"tool_input":{"command":"git clone x $HOMEBREW/wt"}}\tHOMEBREW is not HOME either\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME.bak/wt"}}\ta dot continues the path token into a sibling name\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt"}}\tplus is ordinary sibling filename content\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt"}}\tat-sign is ordinary sibling filename content\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME,bak/wt"}}\tcomma is ordinary sibling filename content\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME:bak/wt"}}\tcolon is ordinary sibling filename content\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME=bak/wt"}}\tequals is ordinary sibling filename content\n' + printf '0\t{"tool_input":{"command":"git clone x ${HOME}+bak/wt"}}\tbraced HOME plus suffix is still a sibling\n' + printf '0\t{"tool_input":{"command":"git clone x /home/tester+bak/wt"}}\ta literal plus-suffixed home path is a sibling\n' + printf '0\t{"tool_input":{"command":"git clone x /home/tester@bak/wt"}}\ta literal at-suffixed home path is a sibling\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt;echo x"}}\ta later terminator does not turn a sibling into HOME\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt&& echo x"}}\tand-if after a sibling preserves the allow\n' + printf '0\t{"tool_input":{"command":"git clone x \\"$HOME;bak/wt\\""}}\ta quoted semicolon is filename content, not a boundary\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME\\\\;bak/wt"}}\tan escaped semicolon is filename content, not a boundary\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME\\u001b/wt"}}\ta raw internal-marker byte is encoded as filename content\n' + printf '0\t{"tool_input":{"command":"git clone x /home/tester.bak/wt"}}\ta literal sibling path is not beneath HOME\n' + printf '0\t{"tool_input":{"command":"git clone x /home/testerx/wt"}}\ta longer literal basename is not HOME\n' + printf '0\t{"tool_input":{"command":"git clone x ~root/wt"}}\tanother account tilde is not this account HOME\n' + printf '0\t{"tool_input":{"command":"git clone x $HOME}/wt"}}\ta closing brace without an opening brace is a literal suffix\n' + printf '0\t{"tool_input":{"command":"git clone x ${HOME/wt"}}\tan opening brace without a close is not a HOME expansion\n' + # Quote removal must not create an expansion the shell never performs. + printf '0\t{"tool_input":{"command":"git clone x '"'"'$HOME'"'"'/wt"}}\tsingle-quoted HOME is a literal directory name\n' + printf '0\t{"tool_input":{"command":"git clone x \\\\$HOME/wt"}}\tan escaped dollar makes HOME literal outside quotes\n' + printf '0\t{"tool_input":{"command":"git clone x \\"\\\\$HOME\\"/wt"}}\tan escaped dollar makes HOME literal inside double quotes\n' + printf '0\t{"tool_input":{"command":"git clone x \\"~/wt\\""}}\ttilde does not expand inside double 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 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' + # Round eleven: Git accepts boolean options as a rule-generated family, + # including --no-* negations. Each command below was checked with Git itself: + # `git clone