diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index c1786c5946..46cf4bea67 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -7,6 +7,10 @@ set -uo pipefail +# Fixture git isolation: an inherited GIT_DIR/GIT_WORK_TREE/GIT_CONFIG would +# redirect `git init` / `git config` into the caller's repository. +unset GIT_DIR GIT_WORK_TREE GIT_CONFIG + HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PASS=0 diff --git a/plugins/claude-memory/.claude-plugin/plugin.json b/plugins/claude-memory/.claude-plugin/plugin.json index f75ed93fc2..29fcd9476d 100644 --- a/plugins/claude-memory/.claude-plugin/plugin.json +++ b/plugins/claude-memory/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-memory", - "version": "0.11.1", + "version": "0.11.2", "description": "Keeps a repo's Claude Code memory layer healthy and under your control, against criteria derived from official Claude Code documentation. The audit skill checks the instruction/memory layer (CLAUDE.md, CLAUDE.local.md, .claude/rules/, auto-memory) with a deterministic script-backed spine plus judgment-tier checks. The stateless skill inspects, disables, and (confirm-gated) purges Claude-written auto memory across all settings scopes.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-memory/CHANGELOG.md b/plugins/claude-memory/CHANGELOG.md index 28861bf13a..7cbe17a30e 100644 --- a/plugins/claude-memory/CHANGELOG.md +++ b/plugins/claude-memory/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to the `claude-memory` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.2] + +### Fixed + +- **Fixture isolation now clears `GIT_CONFIG` (#2889).** The audit and + stateless suite helpers already unset the discovery variables; they now + also unset `GIT_CONFIG`, the second leak path that replaces the file + `git config` reads and writes. Test-only; no skill behavior change. + ## [0.11.1] ### Changed diff --git a/plugins/claude-memory/skills/audit/scripts/memory-dir-stats.test.sh b/plugins/claude-memory/skills/audit/scripts/memory-dir-stats.test.sh index a44c0a6391..55651e3d36 100755 --- a/plugins/claude-memory/skills/audit/scripts/memory-dir-stats.test.sh +++ b/plugins/claude-memory/skills/audit/scripts/memory-dir-stats.test.sh @@ -35,7 +35,7 @@ assert_contains() { # Fixture git repos must never inherit an outer hook chain's exported git env. make_repo() { - unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR + unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR GIT_CONFIG mkdir -p "$1" (cd "$1" && git init -q && git config user.email "test@example.com" && git config user.name "test" && git commit -q --allow-empty -m init) } diff --git a/plugins/claude-memory/skills/audit/scripts/memory-index-refs-check.test.sh b/plugins/claude-memory/skills/audit/scripts/memory-index-refs-check.test.sh index 417b8fb112..e08af0f58f 100755 --- a/plugins/claude-memory/skills/audit/scripts/memory-index-refs-check.test.sh +++ b/plugins/claude-memory/skills/audit/scripts/memory-index-refs-check.test.sh @@ -41,7 +41,7 @@ assert_not_contains() { # Fixture git repos must never inherit an outer hook chain's exported git env. make_repo() { - unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR + unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR GIT_CONFIG mkdir -p "$1" (cd "$1" && git init -q && git config user.email "test@example.com" && git config user.name "test" && git commit -q --allow-empty -m init) } diff --git a/plugins/claude-memory/skills/audit/scripts/orphan-rule-check.test.sh b/plugins/claude-memory/skills/audit/scripts/orphan-rule-check.test.sh index b5e8e647ba..caa8afecbd 100755 --- a/plugins/claude-memory/skills/audit/scripts/orphan-rule-check.test.sh +++ b/plugins/claude-memory/skills/audit/scripts/orphan-rule-check.test.sh @@ -42,7 +42,7 @@ assert_not_contains() { # Fixture git repos must never inherit an outer hook chain's exported git env — # otherwise fixture commits mutate the REAL repo. make_repo() { - unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR + unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR GIT_CONFIG mkdir -p "$1" (cd "$1" && git init -q && git config user.email "test@example.com" && git config user.name "test" && git commit -q --allow-empty -m init) } diff --git a/plugins/claude-memory/skills/stateless/scripts/scope-report.test.sh b/plugins/claude-memory/skills/stateless/scripts/scope-report.test.sh index d6276f6988..090ce66d12 100755 --- a/plugins/claude-memory/skills/stateless/scripts/scope-report.test.sh +++ b/plugins/claude-memory/skills/stateless/scripts/scope-report.test.sh @@ -32,7 +32,7 @@ assert_contains() { # Fixture git repos must never inherit an outer hook chain's exported git env. make_repo() { - unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR + unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR GIT_CONFIG mkdir -p "$1" (cd "$1" && git init -q && git config user.email "test@example.com" && git config user.name "test" && git commit -q --allow-empty -m init) } diff --git a/plugins/repo-hygiene/.claude-plugin/plugin.json b/plugins/repo-hygiene/.claude-plugin/plugin.json index 177f16a6f4..46c6435ae5 100644 --- a/plugins/repo-hygiene/.claude-plugin/plugin.json +++ b/plugins/repo-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "repo-hygiene", - "version": "0.10.7", + "version": "0.10.8", "description": "Repo hygiene action-router: /repo-hygiene:clean sweeps reclaimable caches, build artifacts, and stale git metadata, and can realign the working tree to a fresh-pull state \u2014 dry-run-first, with destructive tiers gated behind explicit confirmation and a session-scoped destructive-command guard. Ecosystem targets are detected at runtime; secrets, runtime dependencies, and skill data are preserved by default.", "author": { "name": "Melodic Software", diff --git a/plugins/repo-hygiene/CHANGELOG.md b/plugins/repo-hygiene/CHANGELOG.md index a591945137..be951a846d 100644 --- a/plugins/repo-hygiene/CHANGELOG.md +++ b/plugins/repo-hygiene/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `repo-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.10.8] + +### Fixed + +- **Fixture isolation now clears `GIT_CONFIG` (#2889).** The shared clean + test helper already unset the discovery variables at source time; it now + also unsets `GIT_CONFIG`. Test-only; no skill behavior change. + ## [0.10.7] ### Changed diff --git a/plugins/repo-hygiene/skills/clean/scripts/lib/test-helpers.sh b/plugins/repo-hygiene/skills/clean/scripts/lib/test-helpers.sh index e94fda9bc5..495562c0a3 100644 --- a/plugins/repo-hygiene/skills/clean/scripts/lib/test-helpers.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/lib/test-helpers.sh @@ -17,7 +17,7 @@ readonly _CLEAN_TEST_HELPERS_LOADED=1 # Strip inherited git-hook context so a fixture `git init` in these tests # resolves to the mktemp fixture, never the real repo (a git-hook chain exports # GIT_DIR / GIT_INDEX_FILE etc.). -unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_CONFIG : "${FAILED:=0}" : "${CASE_NUM:=0}" diff --git a/plugins/skill-quality/.claude-plugin/plugin.json b/plugins/skill-quality/.claude-plugin/plugin.json index 639c293dc8..94d4535c5d 100644 --- a/plugins/skill-quality/.claude-plugin/plugin.json +++ b/plugins/skill-quality/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "skill-quality", - "version": "0.17.3", + "version": "0.17.4", "description": "Skill-authoring QA tooling: a static contract checker that runs twenty-four deterministic checks over a Claude Code skill (frontmatter, explicit invocation mode, per-skill listing-entry cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence, precompute opportunity, completion-criteria signal, injection shell-declaration, fresh-eyes declaration conformance), a shared skill-listing budget reporter across a set of skills, and a bundled evals.json schema plus a deterministic eval-quality lint (duplicate case identities, missing fixtures, empty or vague grading criteria, set-coverage warnings). Runs against any repo's skills directory via the convention-resolution ladder \u2014 no baked layout.", "author": { "name": "Melodic Software", diff --git a/plugins/skill-quality/CHANGELOG.md b/plugins/skill-quality/CHANGELOG.md index c8b6778d59..7bf42f823a 100644 --- a/plugins/skill-quality/CHANGELOG.md +++ b/plugins/skill-quality/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `skill-quality` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.17.4] + +### Fixed + +- **Fixture isolation now clears `GIT_CONFIG` (#2889).** `check-skill` and + `check-listing-budget` suites already unset the discovery variables; they + now also unset `GIT_CONFIG`. Test-only; no skill behavior change. + ## [0.17.3] ### Changed diff --git a/plugins/skill-quality/scripts/check-listing-budget.test.sh b/plugins/skill-quality/scripts/check-listing-budget.test.sh index a95255daec..3a7cea2f01 100755 --- a/plugins/skill-quality/scripts/check-listing-budget.test.sh +++ b/plugins/skill-quality/scripts/check-listing-budget.test.sh @@ -19,7 +19,7 @@ TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null -unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX 2>/dev/null || true +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG 2>/dev/null || true git -C "$TMP" init -q git -C "$TMP" config user.email test@example.com git -C "$TMP" config user.name test diff --git a/plugins/skill-quality/scripts/check-skill.test.sh b/plugins/skill-quality/scripts/check-skill.test.sh index c0b2bf95c6..c10ba64ac5 100755 --- a/plugins/skill-quality/scripts/check-skill.test.sh +++ b/plugins/skill-quality/scripts/check-skill.test.sh @@ -24,7 +24,7 @@ trap 'rm -rf "$TMP"' EXIT # Fresh repo, isolated from any ambient git-hook env that would leak into it. export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null -unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX 2>/dev/null || true +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG 2>/dev/null || true git -C "$TMP" init -q git -C "$TMP" config user.email test@example.com git -C "$TMP" config user.name test diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index df46b46478..554b518cfa 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "source-control", - "version": "0.54.14", + "version": "0.54.15", "description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-authored-by trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop \u2014 safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and standing merge-rung raises binding from the team-tracked layer only \u2014 with one named exception, where an invocation line explicitly typing both the autopilot tier keyword and the dedicated raise argument --merge c3-this-run widens that single invocation's merge authority up to C3 behind a fresh independent frontier-tier resolver, while C4-structural and C5-untrusted-provenance stay unconditionally human-merge), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply \u2014 interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep \u2014 never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index a34144eaac..b9ebd4f7fd 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `source-control` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.54.15] + +### Fixed + +- **Fixture isolation now clears `GIT_CONFIG` (#2889).** The plugin test + helper already unset the discovery variables at source time; it now also + unsets `GIT_CONFIG`. Test-only; no skill behavior change. + ## [0.54.14] ### Changed diff --git a/plugins/source-control/scripts/test-helpers.sh b/plugins/source-control/scripts/test-helpers.sh index 9500fcbe12..8a67b0c71c 100644 --- a/plugins/source-control/scripts/test-helpers.sh +++ b/plugins/source-control/scripts/test-helpers.sh @@ -18,7 +18,7 @@ readonly _TESTS_LIB_LOADED=1 # Strip inherited git-hook context so fixture `git init` / ref writes never # resolve to the real repo when a test runs under a git hook chain. -unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_CONFIG : "${FAILED:=0}" : "${CASE_NUM:=0}" diff --git a/plugins/work-items/.claude-plugin/plugin.json b/plugins/work-items/.claude-plugin/plugin.json index ed5d80484c..591a19a576 100644 --- a/plugins/work-items/.claude-plugin/plugin.json +++ b/plugins/work-items/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "work-items", - "version": "0.39.10", + "version": "0.39.11", "description": "Manages development work items through a provider-neutral tracker seam that ships with the plugin (bundled dispatcher plus github, local-markdown, jira, gitea, and linear adapters; seam plugin-dir canonical, adapters consumer-local-first): dashboard, taxonomy-labeled creation, a race-safe assignee-plus-lease claim protocol, recurring-schedule checks, TODO scanning, stale-lease auditing, plan decomposition into vertical-slice items, a macro-journey router over spec containers (rollup, per-container execution shape, next-step routing), raw-intake triage (issues and unsolicited PRs through raw, verified, briefed, autonomous-eligible states), plus the two work-items loop lanes of the loop-lane convention: a self-paced autonomous work-loop drain (work-class admission gate, adaptive item cap, PR-only) and an attended attend-queue escalation lane. The re-runnable setup skill binds the provider (.work-item-tracker.json), seeds the recurring-schedule seam (.github/recurring-schedule.json), and remaps canonical role labels.", "author": { "name": "Melodic Software", diff --git a/plugins/work-items/CHANGELOG.md b/plugins/work-items/CHANGELOG.md index 26086446ac..393f90f2f4 100644 --- a/plugins/work-items/CHANGELOG.md +++ b/plugins/work-items/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `work-items` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.39.11] + +### Fixed + +- **Fixture isolation now clears `GIT_CONFIG` (#2889).** The tracker test + harness already unset the discovery variables at source time; it now also + unsets `GIT_CONFIG`. Test-only; no skill behavior change. + ## [0.39.10] ### Added diff --git a/plugins/work-items/tools/work-item-tracker/tests/lib.sh b/plugins/work-items/tools/work-item-tracker/tests/lib.sh index b2ea6a8aa5..46046b0b78 100644 --- a/plugins/work-items/tools/work-item-tracker/tests/lib.sh +++ b/plugins/work-items/tools/work-item-tracker/tests/lib.sh @@ -25,7 +25,7 @@ readonly _WIT_TESTS_LIB_LOADED=1 # never resolve to the real repo instead of its throwaway dir (a git hook chain # exports GIT_DIR / GIT_INDEX_FILE etc.; leaving them set has rewritten a real # repo's refs in the past). Every seam test gets a clean git environment. -unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_CONFIG # Defensive counter defaults so the first assertion in a test that forgot to # declare them does not trip `set -u`. diff --git a/scripts/check-fixture-git-isolation.sh b/scripts/check-fixture-git-isolation.sh index 22c4e0dce8..a6038c0fbc 100755 --- a/scripts/check-fixture-git-isolation.sh +++ b/scripts/check-fixture-git-isolation.sh @@ -37,14 +37,13 @@ # # unset GIT_DIR GIT_WORK_TREE GIT_CONFIG # -# GIT_CONFIG belongs in every clear list and the harnesses here all carry it: it +# GIT_CONFIG belongs in every clear list and the CREDIT signal requires it: it # is a SECOND leak path, not another spelling of the first, because it replaces # the file the `git config` subcommand reads and writes rather than redirecting -# discovery, so it survives `-C` and a cleared GIT_DIR alike. The CREDIT signal -# below is deliberately narrower than the recommended list — GIT_DIR plus -# GIT_WORK_TREE — because widening it would retroactively re-violate every suite -# in the corpus that already clears correctly for the discovery path. Widening -# it is tracked in #2889 rather than smuggled in here. +# discovery, so it survives `-C` and a cleared GIT_DIR alike. Official docs +# (git-config ENVIRONMENT): if no `--file` is given, `GIT_CONFIG` is used as +# if it were `--file`. That is why a suite that only unsets the discovery +# pair is still exposed. # # or if it sources a harness that does — scripts/test-git-helpers.sh is the # repo's shared one. Sourced harnesses are resolved by BASENAME against the @@ -64,8 +63,11 @@ # os.environ.pop(_leaked_git_var, None) # # Because that idiom spans lines, the Python check is FILE-scoped rather than -# line-scoped: the file must perform an environ pop/delete AND name both -# load-bearing variables. Python has no `source` equivalent here, so a Python +# line-scoped: the file must perform an environ pop/delete AND name every +# load-bearing variable (GIT_DIR, GIT_WORK_TREE, GIT_CONFIG). A module-level +# name bound to a literal of those names, then iterated into the pop, is the +# same clear — the loop-variable tie follows that binding, not only an +# in-header literal. Python has no `source` equivalent here, so a Python # suite must carry its own clear; there is no harness-inheritance path to # resolve, and that is recorded as a deliberate limit rather than an oversight. # @@ -110,10 +112,17 @@ esac # every classification below is ONE bulk awk pass over the whole tracked set # rather than a grep per file. Three passes total, not eleven hundred. # -# CLEARS: a shell file that unsets both load-bearing variables on one line, or -# a Python file that pops/deletes them from os.environ and names both. The -# Python arm is FILE-scoped because the idiom spans lines (a tuple of names -# iterated into os.environ.pop), so its verdict is emitted at end-of-file. +# CLEARS: a shell file that unsets every load-bearing variable (GIT_DIR, +# GIT_WORK_TREE, GIT_CONFIG) on one logical line via `unset` (process-wide), +# or that wraps EVERY fixture command with `env -u`/`--unset` naming each +# (per-command; one wrap does not isolate a later unwrapped write), or a +# Python file that pops/deletes them from os.environ and names all three. +# The Python arm is FILE-scoped because the idiom spans lines (a tuple of +# names iterated into os.environ.pop), so its verdict is emitted at +# end-of-file. The shell arm joins physical lines while the previous line +# ends in an unescaped backslash, so a continued `git … \` / `unset … \` +# is one unit (the same recall the Python arm already had for +# formatter-wrapped argv). # FIXTURE: a suite that runs a repository-creating or identity-writing git # command. Comments are stripped first, so prose about `git init` in a header # does not conscript a suite that never touches a fixture. The command form is @@ -138,8 +147,9 @@ scan() { OPEN = "[[({]" CLOSE = "[]})]" # The variables a clear must name to earn credit, shared by both arms so - # the two languages cannot drift apart. - NREQ = 2; REQ[1] = "GIT_DIR"; REQ[2] = "GIT_WORK_TREE" + # the two languages cannot drift apart. GIT_CONFIG is the third because + # it is a distinct leak path (git-config ENVIRONMENT: used as --file). + NREQ = 3; REQ[1] = "GIT_DIR"; REQ[2] = "GIT_WORK_TREE"; REQ[3] = "GIT_CONFIG" # A joined Python logical line is capped. An unbalanced bracket inside a # string literal would otherwise accumulate to end of file, and a runaway # join is unsafe for CLEARS specifically: it could sweep an unrelated @@ -218,8 +228,25 @@ scan() { s ~ ("del[ \t]+os[ \t]*\\.[ \t]*environ[ \t]*\\[[ \t]*" v "[ \t]*\\]") } - function py_logical(s, i, v) { + function py_logical(s, i, v, hdr, cname) { if (is_fixture(s)) print "FIXTURE\t" FILENAME + # A module-level name bound to a literal of the required names is the + # other spelling of the in-header tuple. Credit still requires a later + # loop over THAT name to perform the pop — a constant that merely + # mentions the names, or a pop that never met the constant, cannot + # satisfy the tie. + # Any assignment to a name drops its previous CONST binding. Only a + # list/tuple literal then re-populates it — `LEAKED = get_names()` + # must not keep the names an earlier literal left behind. + if (match(s, /^[ \t]*[A-Za-z_][A-Za-z0-9_]*[ \t]*=/)) { + cname = substr(s, RSTART, RLENGTH) + sub(/^[ \t]+/, "", cname) + sub(/[ \t]*=.*/, "", cname) + for (i = 1; i <= NREQ; i++) delete CONST[cname, REQ[i]] + if (s ~ /^[ \t]*[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*[[(]/) + for (i = 1; i <= NREQ; i++) + if (s ~ (Q REQ[i] Q)) CONST[cname, REQ[i]] = 1 + } if (match(s, /for[ \t]+[A-Za-z_][A-Za-z0-9_]*[ \t]+in[ \t]*[[(]/)) { v = substr(s, RSTART, RLENGTH) sub(/^for[ \t]+/, "", v) @@ -232,6 +259,20 @@ scan() { for (i = 1; i <= NREQ; i++) delete BOUND[v, REQ[i]] for (i = 1; i <= NREQ; i++) if (s ~ (Q REQ[i] Q)) BOUND[v, REQ[i]] = 1 + } else if (match(s, /for[ \t]+[A-Za-z_][A-Za-z0-9_]*[ \t]+in[ \t]+[A-Za-z_][A-Za-z0-9_]*/)) { + # `for _v in LEAKED:` — the iterable is a name, not a literal. Copy + # whatever that name currently binds; a rebound constant has already + # cleared CONST above. + hdr = substr(s, RSTART, RLENGTH) + v = hdr + sub(/^for[ \t]+/, "", v) + sub(/[ \t]+in[ \t]+.*/, "", v) + cname = hdr + sub(/^for[ \t]+[A-Za-z_][A-Za-z0-9_]*[ \t]+in[ \t]+/, "", cname) + LOOPVAR[v] = 1 + for (i = 1; i <= NREQ; i++) delete BOUND[v, REQ[i]] + for (i = 1; i <= NREQ; i++) + if (CONST[cname, REQ[i]]) BOUND[v, REQ[i]] = 1 } for (i = 1; i <= NREQ; i++) if (pops_literal(s, REQ[i])) CLEARED[REQ[i]] = 1 @@ -251,11 +292,91 @@ scan() { print "CLEARS\t" cur } + # A shell physical line continues when the comment-stripped text, trimmed, + # ends in an unescaped backslash. `\\` at end of line is an escaped + # backslash, not a continuation; an odd trailing run is a continuation. + function is_cont(s, t) { + t = s + sub(/[ \t]+$/, "", t) + return t ~ /(^|[^\\])(\\\\)*\\$/ + } + + # `env -u NAME` / `env --unset NAME` / `env --unset=NAME` is a correct + # per-command clear. Credit requires the clearing call itself to name + # every required variable, matching the unset-statement rule: a later + # mention on the same line cannot grant it, and naming only the discovery + # pair is not enough. + function env_clears(s, i, ok) { + if (s !~ /(^|[ \t;|&])env[ \t]/) return 0 + for (i = 1; i <= NREQ; i++) { + ok = (s ~ ("-u[ \t]*" REQ[i] "([^A-Za-z0-9_]|$)") || + s ~ ("--unset=" REQ[i] "([^A-Za-z0-9_]|$)") || + s ~ ("--unset[ \t]+" REQ[i] "([^A-Za-z0-9_]|$)")) + if (!ok) return 0 + } + return 1 + } + + function unset_clears(s, u, i) { + if (s !~ /^[ \t]*unset[ \t]/) return 0 + u = s + sub(/^[ \t]*unset[ \t]+/, "", u) + sub(/;.*/, "", u) + for (i = 1; i <= NREQ; i++) + if (u !~ ("(^|[^A-Za-z0-9_])" REQ[i] "([^A-Za-z0-9_]|$)")) return 0 + return 1 + } + + function sh_logical(s, rest, path, fixture, n, i, segs) { + # Score fixture / env-u / unset per STATEMENT, not per physical or + # backslash-joined line. `env -u … git init; git config user.email` + # is two commands: the wrap applies only to the first (GNU env: + # removals apply while it runs COMMAND). A file-wide name presence + # test on the joined string would credit the unwrapped write. + n = split(s, segs, /[ \t]*(\|\||&&|;)[ \t]*/) + for (i = 1; i <= n; i++) { + fixture = is_fixture(segs[i]) + if (fixture) print "FIXTURE\t" FILENAME + # `unset` is process-wide, so one statement isolates every later + # command in the file. `env -u` is per-COMMAND, so a wrap on one + # fixture statement must not credit a later unwrapped identity + # write. Credit env-only when every fixture statement is itself + # an env clear. + if (unset_clears(segs[i])) SH_UNSET = 1 + if (env_clears(segs[i])) SH_ENV = 1 + else if (fixture) SH_ENV_GAP = 1 + } + if (s ~ /^[ \t]*(source|\.)[ \t]+/) { + rest = s + sub(/^[ \t]*(source|\.)[ \t]+/, "", rest) + # Take the first .sh-looking token rather than everything up to the + # first space: `. "$(dirname "$0")/lib/harness.sh"` embeds a space + # INSIDE the path expression, and truncating there loses the basename + # entirely — an under-selection, which is the unsafe direction here. + if (match(rest, /[^ \t"();|&]*\.sh/)) { + path = substr(rest, RSTART, RLENGTH) + sub(/.*\//, "", path) + if (path != "") print "SOURCE\t" FILENAME "\t" path + } + } + } + + function sh_drain() { + if (sh_pending != "") { sh_logical(sh_pending); sh_pending = ""; sh_joined = 0 } + } + + function sh_flush() { + sh_drain() + if (cur != "" && (SH_UNSET || (SH_ENV && !SH_ENV_GAP))) print "CLEARS\t" cur + } + FILENAME != cur { - py_drain(); flush_py() + py_drain(); flush_py(); sh_flush() cur = FILENAME - split("", CLEARED); split("", BOUND); split("", LOOPVAR) + split("", CLEARED); split("", BOUND); split("", LOOPVAR); split("", CONST) pending = ""; depth = 0; joined = 0 + sh_pending = ""; sh_joined = 0 + SH_UNSET = 0; SH_ENV = 0; SH_ENV_GAP = 0 ispy = (FILENAME ~ /\.py$/) } { line = $0; gsub(/\r/, "", line); code = line; sub(/#.*/, "", code) } @@ -280,35 +401,19 @@ scan() { next } - # SHELL ARM. + # SHELL ARM. Same logical-line join as the Python arm, but the join + # signal is a trailing unescaped backslash rather than open brackets: + # `git -C "$d" \ init` and `unset GIT_DIR \ GIT_WORK_TREE \ GIT_CONFIG` + # are each one unit. A runaway join is capped at MAXJOIN. { - if (is_fixture(code)) print "FIXTURE\t" FILENAME - # Comments are already stripped, and the names are matched inside the - # `unset` STATEMENT rather than anywhere on the line, so neither prose nor - # a later command on the same line can grant the credit. - if (code ~ /^[ \t]*unset[ \t]/) { - u = code - sub(/^[ \t]*unset[ \t]+/, "", u) - sub(/;.*/, "", u) - if (u ~ /(^|[^A-Za-z0-9_])GIT_DIR([^A-Za-z0-9_]|$)/ && - u ~ /(^|[^A-Za-z0-9_])GIT_WORK_TREE([^A-Za-z0-9_]|$)/) - print "CLEARS\t" FILENAME - } - if (code ~ /^[ \t]*(source|\.)[ \t]+/) { - rest = code - sub(/^[ \t]*(source|\.)[ \t]+/, "", rest) - # Take the first .sh-looking token rather than everything up to the - # first space: `. "$(dirname "$0")/lib/harness.sh"` embeds a space - # INSIDE the path expression, and truncating there loses the basename - # entirely — an under-selection, which is the unsafe direction here. - if (match(rest, /[^ \t"();|&]*\.sh/)) { - path = substr(rest, RSTART, RLENGTH) - sub(/.*\//, "", path) - if (path != "") print "SOURCE\t" FILENAME "\t" path - } - } + piece = code + cont = is_cont(code) + if (cont) sub(/[ \t]*\\[ \t]*$/, "", piece) + sh_pending = (sh_pending == "" ? piece : sh_pending " " piece) + sh_joined++ + if (!cont || sh_joined >= MAXJOIN) sh_drain() } - END { py_drain(); flush_py() } + END { py_drain(); flush_py(); sh_flush() } ' "$@" } diff --git a/scripts/check-fixture-git-isolation.test.sh b/scripts/check-fixture-git-isolation.test.sh index b1a01fe2e4..6a4c2e7956 100755 --- a/scripts/check-fixture-git-isolation.test.sh +++ b/scripts/check-fixture-git-isolation.test.sh @@ -90,7 +90,7 @@ new_repo r="$REPO" cat >"$r/clean.test.sh" <<'SH' #!/usr/bin/env bash -unset GIT_DIR GIT_WORK_TREE +unset GIT_DIR GIT_WORK_TREE GIT_CONFIG d="$(mktemp -d)" git -C "$d" init -q git -C "$d" config user.email test@example.com @@ -99,11 +99,33 @@ commit_all "$r" out="$(run_gate "$r")" rc=$? if [[ $rc -eq 0 ]]; then - ok "suite that unsets GIT_DIR/GIT_WORK_TREE passes" + ok "suite that unsets GIT_DIR/GIT_WORK_TREE/GIT_CONFIG passes" else fail "self-clearing suite: rc=$rc out='$out'" fi +# --- clearing only the discovery pair is NOT enough (#2889) ------------------ +# GIT_CONFIG is a second leak path (git-config ENVIRONMENT: used as --file), +# so a suite that only unsets GIT_DIR and GIT_WORK_TREE still writes through +# an inherited GIT_CONFIG. +new_repo +r="$REPO" +cat >"$r/no-config.test.sh" <<'SH' +#!/usr/bin/env bash +unset GIT_DIR GIT_WORK_TREE +d="$(mktemp -d)" +git -C "$d" init -q +git -C "$d" config user.email test@example.com +SH +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 1 && "$out" == *"no-config.test.sh"* ]]; then + ok "unsetting GIT_DIR/GIT_WORK_TREE without GIT_CONFIG is still a violation" +else + fail "discovery-only clear: rc=$rc out='$out'" +fi + # --- clearing only GIT_DIR is NOT enough ------------------------------------- new_repo r="$REPO" @@ -129,7 +151,7 @@ r="$REPO" mkdir -p "$r/lib" cat >"$r/lib/harness.sh" <<'SH' # shellcheck shell=bash -unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_CONFIG SH cat >"$r/sourced.test.sh" <<'SH' #!/usr/bin/env bash @@ -193,7 +215,7 @@ new_repo r="$REPO" cat >"$r/fixed.test.sh" <<'SH' #!/usr/bin/env bash -unset GIT_DIR GIT_WORK_TREE +unset GIT_DIR GIT_WORK_TREE GIT_CONFIG d="$(mktemp -d)" git -C "$d" init -q git -C "$d" config user.email test@example.com @@ -385,7 +407,7 @@ new_repo r="$REPO" cat >"$r/test_clean.py" <<'PY' import os, subprocess -for _v in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"): +for _v in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_CONFIG"): os.environ.pop(_v, None) def make(d): subprocess.run(["git", "init", "-q", d], check=True) @@ -395,7 +417,7 @@ commit_all "$r" out="$(run_gate "$r")" rc=$? if [[ $rc -eq 0 ]]; then - ok "python: popping GIT_DIR and GIT_WORK_TREE passes" + ok "python: popping GIT_DIR, GIT_WORK_TREE and GIT_CONFIG passes" else fail "python cleared: rc=$rc out='$out'" fi @@ -419,6 +441,26 @@ else fail "python half-cleared: rc=$rc out='$out'" fi +# --- PYTHON: popping the discovery pair without GIT_CONFIG is NOT enough ----- +new_repo +r="$REPO" +cat >"$r/test_no_config.py" <<'PY' +import os, subprocess +for _v in ("GIT_DIR", "GIT_WORK_TREE"): + os.environ.pop(_v, None) +def make(d): + subprocess.run(["git", "init", "-q", d], check=True) + subprocess.run(["git", "-C", d, "config", "user.email", "test@example.com"], check=True) +PY +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 1 && "$out" == *"test_no_config.py"* ]]; then + ok "python: popping GIT_DIR/GIT_WORK_TREE without GIT_CONFIG is still a violation" +else + fail "python discovery-only clear: rc=$rc out='$out'" +fi + # --- PYTHON: naming the variables without popping anything is NOT enough ----- # Guards the fail-OPEN direction: a file that only mentions GIT_DIR in a comment # must not be credited with clearing it. @@ -483,6 +525,7 @@ import subprocess for _leaked_git_var in ( "GIT_DIR", "GIT_WORK_TREE", + "GIT_CONFIG", ): os.environ.pop(_leaked_git_var, None) @@ -636,6 +679,233 @@ else fail "shell commented unset: rc=$rc out='$out'" fi +# --- SHELL: a backslash-continued git command is still SEEN (#2893) ---------- +# A per-physical-line scan never has `git` and `init` on one line here, so the +# suite would not merely get the wrong verdict — it would be invisible to the +# gate. The Python arm already joined on open brackets; the shell arm now +# joins while the previous line ends in an unescaped backslash. +new_repo +r="$REPO" +cat >"$r/continued-init.test.sh" <<'SH' +#!/usr/bin/env bash +d="$(mktemp -d)" +git -C "$d" \ + init -q +git -C "$d" \ + config \ + user.email t@t.test +SH +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 1 && "$out" == *"continued-init.test.sh"* ]]; then + ok "shell: a backslash-continued git command is still detected as a fixture" +else + fail "shell continued fixture: rc=$rc out='$out'" +fi + +# --- SHELL: a backslash-continued unset of all three PASSES (#2893) ---------- +new_repo +r="$REPO" +cat >"$r/continued-unset.test.sh" <<'SH' +#!/usr/bin/env bash +unset GIT_DIR \ + GIT_WORK_TREE \ + GIT_CONFIG +d="$(mktemp -d)" +git -C "$d" init -q +git -C "$d" config user.email test@example.com +SH +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 0 ]]; then + ok "shell: a backslash-continued unset of all three required names passes" +else + fail "shell continued unset: rc=$rc out='$out'" +fi + +# --- SHELL: env -u naming all three is a clear (#2893) ----------------------- +# Use `$d` rather than `"$(mktemp -d)"` as the -C argument: the latter embeds a +# space, and the fixture matcher (option-and-argument pairs) would then miss +# `init` — the suite would not be a fixture at all, so both this case and the +# half-clear below would "pass" for the wrong reason. +new_repo +r="$REPO" +cat >"$r/env-unset.test.sh" <<'SH' +#!/usr/bin/env bash +d="$(mktemp -d)" +env -u GIT_DIR -u GIT_WORK_TREE -u GIT_CONFIG git -C "$d" init -q +SH +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 0 ]]; then + ok "shell: env -u naming GIT_DIR/GIT_WORK_TREE/GIT_CONFIG passes" +else + fail "shell env -u clear: rc=$rc out='$out'" +fi + +# --- SHELL: every fixture command wrapped with env -u PASSES ----------------- +new_repo +r="$REPO" +cat >"$r/env-all.test.sh" <<'SH' +#!/usr/bin/env bash +d="$(mktemp -d)" +env -u GIT_DIR -u GIT_WORK_TREE -u GIT_CONFIG git -C "$d" init -q +env -u GIT_DIR -u GIT_WORK_TREE -u GIT_CONFIG git -C "$d" config user.email test@example.com +SH +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 0 ]]; then + ok "shell: env -u wrapping every fixture command passes" +else + fail "shell env -u all wrapped: rc=$rc out='$out'" +fi + +# --- SHELL: env -u on one fixture line does not credit an unwrapped write ---- +# env -u is per-COMMAND. A wrap on `init` must not isolate a later bare +# `config user.email` — that is the leak the gate exists to stop. +new_repo +r="$REPO" +cat >"$r/env-mixed.test.sh" <<'SH' +#!/usr/bin/env bash +d="$(mktemp -d)" +env -u GIT_DIR -u GIT_WORK_TREE -u GIT_CONFIG git -C "$d" init -q +git -C "$d" config user.email test@example.com +SH +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 1 && "$out" == *"env-mixed.test.sh"* ]]; then + ok "shell: env -u on one command does not credit an unwrapped identity write" +else + fail "shell env -u mixed: rc=$rc out='$out'" +fi + +# --- SHELL: env -u and an unwrapped write on ONE `;` line is still a gap ----- +# The same leak as env-mixed, reached through one physical line. A +# file-wide name-presence test on the joined string would credit it. +new_repo +r="$REPO" +cat >"$r/env-semi.test.sh" <<'SH' +#!/usr/bin/env bash +d="$(mktemp -d)" +env -u GIT_DIR -u GIT_WORK_TREE -u GIT_CONFIG git -C "$d" init -q; git -C "$d" config user.email test@example.com +SH +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 1 && "$out" == *"env-semi.test.sh"* ]]; then + ok "shell: env -u and an unwrapped write on one semicolon line is still a violation" +else + fail "shell env -u semicolon: rc=$rc out='$out'" +fi + +# --- SHELL: env -u of the discovery pair without GIT_CONFIG is NOT enough ---- +new_repo +r="$REPO" +cat >"$r/env-half.test.sh" <<'SH' +#!/usr/bin/env bash +d="$(mktemp -d)" +env -u GIT_DIR -u GIT_WORK_TREE git -C "$d" init -q +SH +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 1 && "$out" == *"env-half.test.sh"* ]]; then + ok "shell: env -u without GIT_CONFIG is still a violation" +else + fail "shell env -u discovery-only: rc=$rc out='$out'" +fi + +# --- PYTHON: a module-level constant iterated into pop PASSES (#2893) -------- +new_repo +r="$REPO" +cat >"$r/test_const_clear.py" <<'PY' +import os, subprocess +LEAKED = ("GIT_DIR", "GIT_WORK_TREE", "GIT_CONFIG") +for _v in LEAKED: + os.environ.pop(_v, None) +def make(d): + subprocess.run(["git", "init", "-q", d], check=True) + subprocess.run(["git", "-C", d, "config", "user.email", "test@example.com"], check=True) +PY +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 0 ]]; then + ok "python: a module-level constant iterated into pop passes" +else + fail "python const clear: rc=$rc out='$out'" +fi + +# --- PYTHON: a constant that only NAMES the vars does not credit ------------- +new_repo +r="$REPO" +cat >"$r/test_const_mention.py" <<'PY' +import subprocess +LEAKED = ("GIT_DIR", "GIT_WORK_TREE", "GIT_CONFIG") +def make(d): + subprocess.run(["git", "init", "-q", d], check=True) + subprocess.run(["git", "-C", d, "config", "user.email", "test@example.com"], check=True) +PY +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 1 && "$out" == *"test_const_mention.py"* ]]; then + ok "python: a constant naming the variables without popping is still a violation" +else + fail "python const mention: rc=$rc out='$out'" +fi + +# --- PYTHON: a REBOUND constant does not carry a stale binding --------------- +new_repo +r="$REPO" +cat >"$r/test_const_rebound.py" <<'PY' +import os, subprocess +LEAKED = ("GIT_DIR", "GIT_WORK_TREE", "GIT_CONFIG") +LEAKED = ("HOME",) +for _v in LEAKED: + os.environ.pop(_v, None) +def make(d): + subprocess.run(["git", "init", "-q", d], check=True) + subprocess.run(["git", "-C", d, "config", "user.email", "test@example.com"], check=True) +PY +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 1 && "$out" == *"test_const_rebound.py"* ]]; then + ok "python: a rebound constant does not inherit an earlier binding" +else + fail "python const rebound: rc=$rc out='$out'" +fi + +# --- PYTHON: rebinding a constant to a CALL drops the earlier literal -------- +new_repo +r="$REPO" +cat >"$r/test_const_rebind_call.py" <<'PY' +import os, subprocess +def get_names(): + return ("HOME",) +LEAKED = ("GIT_DIR", "GIT_WORK_TREE", "GIT_CONFIG") +LEAKED = get_names() +for _v in LEAKED: + os.environ.pop(_v, None) +def make(d): + subprocess.run(["git", "init", "-q", d], check=True) + subprocess.run(["git", "-C", d, "config", "user.email", "test@example.com"], check=True) +PY +commit_all "$r" +out="$(run_gate "$r")" +rc=$? +if [[ $rc -eq 1 && "$out" == *"test_const_rebind_call.py"* ]]; then + ok "python: rebinding a constant to a call does not keep the earlier literal" +else + fail "python const rebind-call: rc=$rc out='$out'" +fi + # --- SCOPE: a declared counter-fixture is exempt ----------------------------- new_repo r="$REPO" diff --git a/scripts/fixture-git-isolation-baseline.txt b/scripts/fixture-git-isolation-baseline.txt index e977a9a458..8ff55a1382 100644 --- a/scripts/fixture-git-isolation-baseline.txt +++ b/scripts/fixture-git-isolation-baseline.txt @@ -12,7 +12,7 @@ # # To clear an entry, add this once near the top of the suite: # -# unset GIT_DIR GIT_WORK_TREE +# unset GIT_DIR GIT_WORK_TREE GIT_CONFIG # # or source a harness that already does it (scripts/test-git-helpers.sh, or the # plugin-local *-test-helpers.sh siblings). Sourcing is preferred where the